From c17d745665773f12f4642a5a0050ffcce196d122 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 04:55:45 -0700 Subject: [PATCH] FN-7109: notify operators when CLI agents await input Notify external providers when terminal-backed CLI agents pause for tool permission or user input. - Add a dedicated cli-agent-awaiting-input notification event to settings, schema defaults, dashboard options, and provider tests. - Dispatch CLI-agent waiting-on-input notifications from the in-process runtime with task context and prompt-scoped dedupe keys. - Document the operator behavior and add a published package changeset for the new notification surface. Files changed: .changeset/fn-7109-cli-agent-notification.md | 7 ++ docs/agents.md | 4 + docs/settings-reference.md | 4 +- .../cli-agent-permission-notifications.md | 13 +++ .../core/src/__tests__/global-settings.test.ts | 1 + packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 6 ++ .../SettingsModal.remote-notifications.test.tsx | 1 + .../components/__tests__/settings-mobile.test.tsx | 2 +- .../settings/sections/NotificationsSection.tsx | 3 + .../src/__tests__/in-process-runtime.test.ts | 56 ++++++++++- packages/engine/src/__tests__/notifier.test.ts | 6 +- .../engine/src/__tests__/ntfy-provider.test.ts | 4 + .../engine/src/__tests__/webhook-provider.test.ts | 2 + .../engine/src/cli-agent/__tests__/runtime.test.ts | 39 +++++++- .../__tests__/notification-service.test.ts | 56 ++++++++++- .../src/notification/notification-service.ts | 9 +- packages/engine/src/notification/ntfy-provider.ts | 7 ++ .../engine/src/notification/webhook-provider.ts | 2 + packages/engine/src/notifier.ts | 1 + packages/engine/src/runtimes/in-process-runtime.ts | 111 +++++++++++++++++++++ 21 files changed, 326 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7109 Fusion-Task-Lineage: 636c1875-92a2-46a7-847e-1f4955c0822f Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7109-cli-agent-notification.md | 7 ++ docs/agents.md | 4 + docs/settings-reference.md | 4 +- .../cli-agent-permission-notifications.md | 13 ++ .../src/__tests__/global-settings.test.ts | 1 + packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 6 + ...ettingsModal.remote-notifications.test.tsx | 1 + .../__tests__/settings-mobile.test.tsx | 2 +- .../sections/NotificationsSection.tsx | 3 + .../src/__tests__/in-process-runtime.test.ts | 56 ++++++++- .../engine/src/__tests__/notifier.test.ts | 6 +- .../src/__tests__/ntfy-provider.test.ts | 4 + .../src/__tests__/webhook-provider.test.ts | 2 + .../src/cli-agent/__tests__/runtime.test.ts | 39 +++++- .../__tests__/notification-service.test.ts | 56 ++++++++- .../src/notification/notification-service.ts | 9 +- .../engine/src/notification/ntfy-provider.ts | 7 ++ .../src/notification/webhook-provider.ts | 2 + packages/engine/src/notifier.ts | 1 + .../engine/src/runtimes/in-process-runtime.ts | 111 ++++++++++++++++++ 21 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-7109-cli-agent-notification.md create mode 100644 docs/solutions/integration-issues/cli-agent-permission-notifications.md diff --git a/.changeset/fn-7109-cli-agent-notification.md b/.changeset/fn-7109-cli-agent-notification.md new file mode 100644 index 0000000000..e4a37c090e --- /dev/null +++ b/.changeset/fn-7109-cli-agent-notification.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add external notifications for CLI agent tool-permission prompts. +category: feature +dev: Adds cli-agent-awaiting-input notification delivery from CLI waiting-on-input telemetry through ntfy/webhook providers. diff --git a/docs/agents.md b/docs/agents.md index 699724e8be..7439609c5d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -214,6 +214,10 @@ Separation of concerns: - `permissionPolicy` determines how sensitive runtime actions are gated (`allow`, `block`, `require-approval`) once the capability path is in play. - Dashboard persona presets (`packages/dashboard/app/components/agent-presets/`) are UI templates for identity/behavior and are **not** the source of truth for permission-policy enforcement. +### CLI agent permission prompts and notifications + +CLI-agent adapters keep their own autonomy posture and tool-permission handling separate from permanent-agent `permissionPolicy`. When an adapter reports a permission/input prompt (`PermissionRequest`, `Notification`, or a conservative approval-prompt heuristic), the CLI session moves to `waitingOnInput`; the dashboard shows the session banner, and Fusion dispatches the `cli-agent-awaiting-input` notification event through enabled ntfy/webhook providers. Repeated waiting events for the same CLI session are de-duplicated before provider delivery, while the in-app banner continues to reflect the live session state. + ### System-Managed Fields (Not User-Editable) These fields are managed by the engine and cannot be directly edited: diff --git a/docs/settings-reference.md b/docs/settings-reference.md index f72bd6b828..67d13c3b2a 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -66,7 +66,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. | | `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer ` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. | -| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and still needs re-authentication after any automatic refresh path has been tried; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | +| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "cli-agent-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","cli-agent-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `cli-agent-awaiting-input` fires when a CLI agent session is waiting on terminal input, including tool-permission prompts; these notifications are de-duplicated per CLI session and include task/session metadata for deep links. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and still needs re-authentication after any automatic refresh path has been tried; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message and CLI-agent events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | | `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. | | `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. | | `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. | @@ -212,7 +212,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports | `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). | | `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. | | `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer ` on ntfy publishes. | -| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired and cannot be automatically refreshed; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. | +| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "cli-agent-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. `cli-agent-awaiting-input` is emitted when a CLI agent session waits on terminal input or a tool-permission prompt. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired and cannot be automatically refreshed; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. | | `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. | Disable daily update checks globally: diff --git a/docs/solutions/integration-issues/cli-agent-permission-notifications.md b/docs/solutions/integration-issues/cli-agent-permission-notifications.md new file mode 100644 index 0000000000..24885fe627 --- /dev/null +++ b/docs/solutions/integration-issues/cli-agent-permission-notifications.md @@ -0,0 +1,13 @@ +--- +category: integration-issues +module: engine/cli-agent +tags: [cli-agent, notifications, permissions, ntfy, webhook] +problem_type: runtime-wiring +applies_when: CLI-agent sessions enter waitingOnInput but external notification providers do not receive permission/input alerts +--- + +# CLI-agent permission prompts must wire `TelemetryHub.onNotification` + +CLI adapters normalize tool-permission and terminal-input prompts to `TelemetryHub.ingest(..., { kind: "waitingOnInput" })`. The hub updates session state for the in-app banner and invokes the optional `onNotification` callback for external delivery. + +If a runtime constructs `createCliAgentRuntime(...)` without `onNotification`, the dashboard can still show `waiting_on_input`, but ntfy/webhook providers never receive the alert. Wire runtime construction to dispatch `cli-agent-awaiting-input` through `getActiveNotificationService()?.dispatch(...)`, include task/session metadata, and provide a `notificationDedupeKey` so duplicate waiting telemetry does not spam providers. diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index a18cc5b5ea..9773ed100c 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -375,6 +375,7 @@ describe("GlobalSettingsStore", () => { "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "message:agent-to-user", "message:agent-to-agent", "message:room", diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 0e917cd48a..e3e9f7d4ae 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -96,6 +96,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "message:agent-to-user", "message:agent-to-agent", "message:room", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f0ca6bf240..768bb7e53f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -693,6 +693,7 @@ export type NtfyNotificationEvent = | "awaiting-approval" | "awaiting-user-review" | "planning-awaiting-input" + | "cli-agent-awaiting-input" | "gridlock" | "board-stall-unrecovered" | "db-corruption-detected" @@ -714,6 +715,11 @@ export const NOTIFICATION_EVENTS = [ "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + /* + * FNXC:ToolPermissionNotifications 2026-06-27-00:00: + * CLI tool-permission requests are a distinct user-facing notification event from plan approval. Operators must be able to enable external alerts when a terminal-backed agent waits for human input. + */ + "cli-agent-awaiting-input", "gridlock", "board-stall-unrecovered", "db-corruption-detected", diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx index efadf9dc89..1d345884e9 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx @@ -707,6 +707,7 @@ describe("SettingsModal", () => { "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "gridlock", "fallback-used", "memory-dreams-processed", diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index aa8b96e995..2eaf204426 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -29,7 +29,7 @@ const defaultSettings = { defaultPresetBySize: {}, ntfyEnabled: false, ntfyTopic: undefined, - ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "fallback-used", "memory-dreams-processed", "oauth-token-expired"], + ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "cli-agent-awaiting-input", "fallback-used", "memory-dreams-processed", "oauth-token-expired"], webhookEnabled: false, webhookUrl: undefined, webhookFormat: undefined, diff --git a/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx index 2c296dd33a..3fb39d7605 100644 --- a/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx @@ -10,6 +10,7 @@ export const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [ "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "gridlock", "fallback-used", "memory-dreams-processed", @@ -29,6 +30,8 @@ export const NOTIFICATION_EVENT_OPTIONS: Array<{ { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, + // FNXC:ToolPermissionNotifications 2026-06-27-00:00: Settings must expose CLI-agent awaiting-input alerts separately from plan approval so operators can opt into external notifications for blocked terminal tool permissions. + { event: "cli-agent-awaiting-input", label: "CLI agent needs input", description: "When a CLI agent is blocked on a tool permission or terminal input prompt" }, { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, diff --git a/packages/engine/src/__tests__/in-process-runtime.test.ts b/packages/engine/src/__tests__/in-process-runtime.test.ts index e81119672a..8066f13ead 100644 --- a/packages/engine/src/__tests__/in-process-runtime.test.ts +++ b/packages/engine/src/__tests__/in-process-runtime.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { EventEmitter } from "node:events"; -import { InProcessRuntime } from "../runtimes/in-process-runtime.js"; +import type { CliSession } from "@fusion/core"; +import { + buildCliAgentAwaitingInputNotificationPayload, + InProcessRuntime, +} from "../runtimes/in-process-runtime.js"; describe("InProcessRuntime onStart duplicate guard", () => { it("contains a taskAgentMap guard before creating task-worker agents", () => { @@ -24,6 +28,56 @@ describe("InProcessRuntime onStart duplicate guard", () => { expect(source).toContain("activeMissionAutopilot.recoverMissions(activeMissionStore)"); }); + it("builds prompt-scoped CLI input notification dedupe keys", () => { + const makeSession = (updatedAt: string): CliSession => ({ + id: "cli-1", + taskId: "FN-7109", + chatSessionId: null, + purpose: "execute", + projectId: "proj-test", + adapterId: "claude-code", + agentState: "waitingOnInput", + terminationReason: null, + nativeSessionId: null, + resumeAttempts: 0, + autonomyPosture: null, + worktreePath: null, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt, + }); + + const first = buildCliAgentAwaitingInputNotificationPayload({ + projectId: "proj-test", + info: { + sessionId: "cli-1", + notification: { kind: "permission_request", toolName: "Bash", prompt: "run tests" }, + }, + session: makeSession("2026-06-27T00:01:00.000Z"), + task: undefined, + }); + const duplicate = buildCliAgentAwaitingInputNotificationPayload({ + projectId: "proj-test", + info: { + sessionId: "cli-1", + notification: { prompt: "run tests", toolName: "Bash", kind: "permission_request" }, + }, + session: makeSession("2026-06-27T00:01:00.000Z"), + task: undefined, + }); + const nextPromptSameSession = buildCliAgentAwaitingInputNotificationPayload({ + projectId: "proj-test", + info: { + sessionId: "cli-1", + notification: { kind: "permission_request", toolName: "Bash", prompt: "run build" }, + }, + session: makeSession("2026-06-27T00:02:00.000Z"), + task: undefined, + }); + + expect(first.metadata?.notificationDedupeKey).toBe(duplicate.metadata?.notificationDedupeKey); + expect(nextPromptSameSession.metadata?.notificationDedupeKey).not.toBe(first.metadata?.notificationDedupeKey); + }); + it("forwards task:deleted events with and without githubIssueAction metadata", () => { const runtime = new InProcessRuntime( { diff --git a/packages/engine/src/__tests__/notifier.test.ts b/packages/engine/src/__tests__/notifier.test.ts index e14322a584..93f0014f9b 100644 --- a/packages/engine/src/__tests__/notifier.test.ts +++ b/packages/engine/src/__tests__/notifier.test.ts @@ -19,6 +19,8 @@ describe("Ntfy notifier helpers", () => { it("includes mailbox message events in default events", () => { expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input"); expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input"); + expect(DEFAULT_NTFY_EVENTS).toContain("cli-agent-awaiting-input"); + expect(resolveNtfyEvents(undefined)).toContain("cli-agent-awaiting-input"); expect(DEFAULT_NTFY_EVENTS).toContain("gridlock"); expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used"); expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user"); @@ -26,9 +28,11 @@ describe("Ntfy notifier helpers", () => { expect(DEFAULT_NTFY_EVENTS).toContain("message:room"); }); - it("checks planning-awaiting-input event enablement", () => { + it("checks awaiting-input event enablement", () => { expect(isNtfyEventEnabled(["planning-awaiting-input"], "planning-awaiting-input")).toBe(true); expect(isNtfyEventEnabled(["failed"], "planning-awaiting-input")).toBe(false); + expect(isNtfyEventEnabled(["cli-agent-awaiting-input"], "cli-agent-awaiting-input")).toBe(true); + expect(isNtfyEventEnabled(["failed"], "cli-agent-awaiting-input")).toBe(false); }); it("supports task-created enablement while keeping it default-off", () => { diff --git a/packages/engine/src/__tests__/ntfy-provider.test.ts b/packages/engine/src/__tests__/ntfy-provider.test.ts index 23611bd8b0..efe4813500 100644 --- a/packages/engine/src/__tests__/ntfy-provider.test.ts +++ b/packages/engine/src/__tests__/ntfy-provider.test.ts @@ -36,6 +36,7 @@ describe("NtfyNotificationProvider", () => { "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "fallback-used", "message:agent-to-user", "message:agent-to-agent", @@ -57,6 +58,7 @@ describe("NtfyNotificationProvider", () => { ["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"], ["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"], ["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"], + ["cli-agent-awaiting-input", "CLI agent input needed for FN-1", "permission_request", "high"], ["fallback-used", "Fallback model used for FN-1", "switched from", "high"], ["task-created", "New task FN-1 created by agent", "Triage Bot created \"T\"", "default"], ["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"], @@ -82,6 +84,7 @@ describe("NtfyNotificationProvider", () => { providerId: "openai-codex", providerName: "OpenAI Codex", agentName: "Triage Bot", + notificationKind: "permission_request", }, }); @@ -108,6 +111,7 @@ describe("NtfyNotificationProvider", () => { expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true); expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true); expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true); + expect(provider.isEventSupported("cli-agent-awaiting-input" as any)).toBe(true); expect(provider.isEventSupported("fallback-used" as any)).toBe(true); expect(provider.isEventSupported("task-created" as any)).toBe(true); expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true); diff --git a/packages/engine/src/__tests__/webhook-provider.test.ts b/packages/engine/src/__tests__/webhook-provider.test.ts index 601866fcde..cfb72b3be7 100644 --- a/packages/engine/src/__tests__/webhook-provider.test.ts +++ b/packages/engine/src/__tests__/webhook-provider.test.ts @@ -163,6 +163,7 @@ describe("WebhookNotificationProvider", () => { ["awaiting-approval", "needs your approval before it can proceed"], ["awaiting-user-review", "needs human review before it can proceed"], ["planning-awaiting-input", "is awaiting your input during planning"], + ["cli-agent-awaiting-input", "has a CLI agent waiting for permission_request"], ["gridlock", "Pipeline gridlocked"], ["fallback-used", "Fusion recovered by switching from"], ["message:agent-to-user", "From: Triage Bot → You: hello"], @@ -187,6 +188,7 @@ describe("WebhookNotificationProvider", () => { roomId: "room-1", roomName: "Incident Room", preview: "hello", + notificationKind: "permission_request", }, }); diff --git a/packages/engine/src/cli-agent/__tests__/runtime.test.ts b/packages/engine/src/cli-agent/__tests__/runtime.test.ts index c504d9c52f..af18e9e96d 100644 --- a/packages/engine/src/cli-agent/__tests__/runtime.test.ts +++ b/packages/engine/src/cli-agent/__tests__/runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -41,7 +41,7 @@ interface Harness { fusionDir: string; } -function makeHarness(): Harness { +function makeHarness(options: Partial[0]> = {}): Harness { const tmpDir = mkdtempSync(join(tmpdir(), "fn-cli-runtime-test-")); const fusionDir = join(tmpDir, ".fusion"); const db = new Database(fusionDir, { inMemory: true }); @@ -52,6 +52,7 @@ function makeHarness(): Harness { projectId: "proj-1", hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks", managerOptions: { loadPty: async () => makeMockPtyModule() }, + ...options, }); return { runtime, db, tmpDir, fusionDir }; } @@ -79,6 +80,40 @@ describe("createCliAgentRuntime", () => { expect(bundle.hookEndpointUrl).toBe("http://127.0.0.1:4040/api/cli-agent/hooks"); }); + it("forwards waitingOnInput permission notifications through the runtime hub", () => { + const onNotification = vi.fn(); + h.runtime.dispose(); + h.runtime = createCliAgentRuntime({ + fusionDir: h.fusionDir, + db: h.db, + projectId: "proj-1", + hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks", + managerOptions: { loadPty: async () => makeMockPtyModule() }, + onNotification, + }); + const adapterId = BUNDLED_CLI_ADAPTERS[0].id; + const session = h.runtime.bundle.store.createSession({ + adapterId, + projectId: "proj-1", + purpose: "execute", + taskId: "FN-7109", + worktreePath: "/wt/permission", + agentState: "busy", + }); + + h.runtime.bundle.hub.issueToken(session.id); + h.runtime.bundle.hub.ingest(session.id, { + kind: "waitingOnInput", + payload: { notification: { kind: "permission_request", toolName: "Bash" } }, + }); + + expect(onNotification).toHaveBeenCalledTimes(1); + expect(onNotification).toHaveBeenCalledWith({ + sessionId: session.id, + notification: { kind: "permission_request", toolName: "Bash" }, + }); + }); + it("registers all bundled adapters into a per-runtime registry", () => { const ids = h.runtime.bundle.registry.ids().sort(); const expected = BUNDLED_CLI_ADAPTERS.map((a) => a.id).sort(); diff --git a/packages/engine/src/notification/__tests__/notification-service.test.ts b/packages/engine/src/notification/__tests__/notification-service.test.ts index b90368f57a..0adaa4c0c7 100644 --- a/packages/engine/src/notification/__tests__/notification-service.test.ts +++ b/packages/engine/src/notification/__tests__/notification-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { NotificationProvider, Settings, Task } from "@fusion/core"; +import type { NotificationPayload, NotificationProvider, Settings, Task } from "@fusion/core"; import { NotificationService } from "../notification-service.js"; import { schedulerLog } from "../../logger.js"; @@ -304,3 +304,57 @@ describe("NotificationService deferred failure notifications", () => { expect(sendNotification).not.toHaveBeenCalled(); }); }); + +describe("NotificationService manual dispatch dedupe", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + async function setup(settings: Partial = {}) { + const store = createStore(settings); + const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" })); + const provider: NotificationProvider = { + getProviderId: () => "mock", + isEventSupported: () => true, + sendNotification, + }; + const service = new NotificationService(store as any); + service.registerProvider(provider); + await service.start(); + return { service, sendNotification }; + } + + it("suppresses duplicate CLI permission notifications using a metadata dedupe key", async () => { + const { service, sendNotification } = await setup(); + const payload: NotificationPayload = { + taskId: "FN-7109", + event: "cli-agent-awaiting-input", + metadata: { + notificationDedupeKey: "cli-agent:proj-1:session-1:cli-agent-awaiting-input", + notificationKind: "permission_request", + }, + }; + + await service.dispatch("cli-agent-awaiting-input", payload); + await service.dispatch("cli-agent-awaiting-input", payload); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sendNotification).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledWith("cli-agent-awaiting-input", expect.objectContaining({ taskId: "FN-7109" })); + await service.stop(); + }); + + it("no-ops manual dispatch cleanly when notifications are disabled", async () => { + const { service, sendNotification } = await setup({ ntfyEnabled: false, ntfyTopic: undefined }); + + await service.dispatch("cli-agent-awaiting-input", { + taskId: "FN-7109", + event: "cli-agent-awaiting-input", + metadata: { notificationDedupeKey: "cli-agent:disabled" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sendNotification).not.toHaveBeenCalled(); + await service.stop(); + }); +}); diff --git a/packages/engine/src/notification/notification-service.ts b/packages/engine/src/notification/notification-service.ts index 99770ff3f6..78af12ae0d 100644 --- a/packages/engine/src/notification/notification-service.ts +++ b/packages/engine/src/notification/notification-service.ts @@ -707,7 +707,14 @@ export class NotificationService { } private maybeNotify(taskId: string, eventType: NotificationEvent, payload: NotificationPayload): void { - const key = `${taskId}:${eventType}`; + const metadataDedupeKey = typeof payload.metadata?.notificationDedupeKey === "string" + ? payload.metadata.notificationDedupeKey.trim() + : ""; + /* + * FNXC:ToolPermissionNotifications 2026-06-27-00:00: + * Some notification surfaces are not task-lifecycle events. Honor a caller-provided dedupe key so CLI tool-permission prompts can suppress repeated telemetry records without suppressing unrelated future task notifications. + */ + const key = metadataDedupeKey.length > 0 ? metadataDedupeKey : `${taskId}:${eventType}`; if (this.notifiedEvents.has(key)) { schedulerLog.log(`NotificationService.maybeNotify suppressed duplicate key=${key}`); return; diff --git a/packages/engine/src/notification/ntfy-provider.ts b/packages/engine/src/notification/ntfy-provider.ts index 2a1f608dae..1a32527a69 100644 --- a/packages/engine/src/notification/ntfy-provider.ts +++ b/packages/engine/src/notification/ntfy-provider.ts @@ -37,6 +37,7 @@ type SupportedNtfyEvent = | "awaiting-approval" | "awaiting-user-review" | "planning-awaiting-input" + | "cli-agent-awaiting-input" | "fallback-used" | "task-created" | "workflow-notify" @@ -52,6 +53,7 @@ const SUPPORTED_EVENTS = new Set([ "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "fallback-used", "task-created", "workflow-notify", @@ -227,6 +229,11 @@ export class NtfyNotificationProvider implements NotificationProvider { message: `Task "${identifier}" is awaiting your input during planning`, priority: "high", }, + "cli-agent-awaiting-input": { + title: `CLI agent input needed for ${taskId}`, + message: `Task "${identifier}" has a CLI agent waiting for ${String(payload.metadata?.notificationKind ?? "human input")}`, + priority: "high", + }, "fallback-used": { title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`, message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`, diff --git a/packages/engine/src/notification/webhook-provider.ts b/packages/engine/src/notification/webhook-provider.ts index 1eecb812ce..b4d646e707 100644 --- a/packages/engine/src/notification/webhook-provider.ts +++ b/packages/engine/src/notification/webhook-provider.ts @@ -170,6 +170,8 @@ export class WebhookNotificationProvider implements NotificationProvider { return `Task "${identifier}" needs human review before it can proceed`; case "planning-awaiting-input": return `Task "${identifier}" is awaiting your input during planning`; + case "cli-agent-awaiting-input": + return `Task "${identifier}" has a CLI agent waiting for ${String(payload.metadata?.notificationKind ?? "human input")}`; case "gridlock": return "Pipeline gridlocked"; case "fallback-used": diff --git a/packages/engine/src/notifier.ts b/packages/engine/src/notifier.ts index 7409177603..ff986b67a6 100644 --- a/packages/engine/src/notifier.ts +++ b/packages/engine/src/notifier.ts @@ -30,6 +30,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [ "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", + "cli-agent-awaiting-input", "gridlock", "board-stall-unrecovered", "db-corruption-detected", diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index b8f8b074fb..1fdd9eff15 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import type { TaskStore, @@ -11,6 +12,8 @@ import type { MessageStore, RoutineStore, GithubIssueAction, + CliSession, + NotificationPayload, } from "@fusion/core"; import { ChatStore, createCentralDatabase, isEphemeralAgent } from "@fusion/core"; import { Scheduler } from "../scheduler.js"; @@ -36,6 +39,7 @@ import type { ProjectRuntimeEvents, } from "../project-runtime.js"; import { runtimeLog } from "../logger.js"; +import { getActiveNotificationService } from "../notifier.js"; import { StuckTaskDetector } from "../stuck-task-detector.js"; import type { UsageLimitPauser } from "../usage-limit-detector.js"; import { SelfHealingManager, VALIDATOR_RUN_STALE_MAX_AGE_MS } from "../self-healing.js"; @@ -53,6 +57,79 @@ import { setImmediate as setImmediateCb } from "node:timers"; const yieldEventLoop = (): Promise => new Promise((resolve) => setImmediateCb(resolve)); +export const CLI_AGENT_AWAITING_INPUT_EVENT = "cli-agent-awaiting-input" as const; + +export interface CliAgentAwaitingInputNotificationInfo { + sessionId: string; + notification: Record | undefined; +} + +function stableNotificationJson(value: unknown): string { + if (value === undefined) { + return "undefined"; + } + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? String(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableNotificationJson(entry)).join(",")}]`; + } + const record = value as Record; + const fields = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableNotificationJson(record[key])}`); + return `{${fields.join(",")}}`; +} + +function buildCliAgentNotificationDedupeKey(input: { + projectId: string; + info: CliAgentAwaitingInputNotificationInfo; + session: CliSession | undefined; +}): string { + const notificationFingerprint = createHash("sha256") + .update(stableNotificationJson(input.info.notification ?? null)) + .digest("hex") + .slice(0, 16); + const waitingEpoch = input.session?.updatedAt ?? "unknown-waiting-epoch"; + return [ + "cli-agent", + input.projectId, + input.info.sessionId, + CLI_AGENT_AWAITING_INPUT_EVENT, + waitingEpoch, + notificationFingerprint, + ].join(":"); +} + +export function buildCliAgentAwaitingInputNotificationPayload(input: { + projectId: string; + info: CliAgentAwaitingInputNotificationInfo; + session: CliSession | undefined; + task: Task | undefined; +}): NotificationPayload { + const taskId = input.session?.taskId ?? undefined; + const adapterId = input.session?.adapterId; + const notificationKind = typeof input.info.notification?.kind === "string" + ? input.info.notification.kind + : "waiting_on_input"; + + return { + ...(taskId ? { taskId } : {}), + taskTitle: input.task?.title, + taskDescription: input.task?.description, + event: CLI_AGENT_AWAITING_INPUT_EVENT, + metadata: { + sessionId: input.info.sessionId, + projectId: input.projectId, + ...(adapterId ? { adapterId } : {}), + notificationKind, + notification: input.info.notification ?? null, + // FNXC:ToolPermissionNotifications 2026-06-27-00:00: CLI adapters can emit duplicate waiting-on-input records for one blocked prompt. The external notification path carries a waiting-epoch plus prompt-fingerprint key so repeated telemetry for the same blocked prompt does not spam providers, while later tool requests in the same session still notify operators. + notificationDedupeKey: buildCliAgentNotificationDedupeKey(input), + }, + }; +} + /** * InProcessRuntime runs a project within the main process. * @@ -417,6 +494,13 @@ export class InProcessRuntime db: this.taskStore.getDatabase(), projectId: this.config.projectId, hookEndpointUrl: this.resolveCliAgentHookEndpointUrl(), + onNotification: (info) => { + /* + * FNXC:ToolPermissionNotifications 2026-06-27-00:00: + * CLI tool-permission prompts must notify operators through configured external providers, not only through in-app session state. Keep this callback wired to the active NotificationService so ntfy/webhook users see blocked terminal sessions. + */ + void this.dispatchCliAgentAwaitingInputNotification(info); + }, }); runtimeLog.log("CLI Agent Executor runtime initialized"); } catch (cliErr) { @@ -1335,6 +1419,33 @@ export class InProcessRuntime return this.cliAgentRuntime; } + private async dispatchCliAgentAwaitingInputNotification( + info: CliAgentAwaitingInputNotificationInfo, + ): Promise { + const notificationService = getActiveNotificationService(); + if (!notificationService) { + return; + } + + const session = this.cliAgentRuntime?.bundle.store.getSession(info.sessionId); + let task: Task | undefined; + if (session?.taskId) { + try { + task = await this.taskStore.getTask(session.taskId); + } catch { + task = undefined; + } + } + + const payload = buildCliAgentAwaitingInputNotificationPayload({ + projectId: this.config.projectId, + info, + session, + task, + }); + await notificationService.dispatch(CLI_AGENT_AWAITING_INPUT_EVENT, payload); + } + /** * Resolve the dashboard CLI-agent hook ingestion endpoint URL. Prefers the * value threaded from server boot (once the listening port is known); falls