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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 04:55:45 -07:00
parent e687ff3700
commit c17d745665
21 changed files with 326 additions and 9 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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 <token>` 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 <token>` 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:

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -707,6 +707,7 @@ describe("SettingsModal", () => {
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"cli-agent-awaiting-input",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -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,

View File

@@ -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" },

View File

@@ -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(
{

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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",
},
});

View File

@@ -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<Parameters<typeof createCliAgentRuntime>[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();

View File

@@ -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<Settings> = {}) {
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();
});
});

View File

@@ -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;

View File

@@ -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<SupportedNtfyEvent>([
"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")}).`,

View File

@@ -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":

View File

@@ -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",

View File

@@ -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<void> => 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<string, unknown> | 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<string, unknown>;
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<void> {
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