diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 10:12:11 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 10:12:11 +0900 |
| commit | 9c93086c0d4acaa1ed753488b12f72c2ca86a22c (patch) | |
| tree | e74597029db76d50ea6e48c2bb9bcc62a84fd079 /packages/core/src | |
| parent | 41857893e0a3af7afb7b716a2274dc4aec29e61c (diff) | |
| download | dispatch-9c93086c0d4acaa1ed753488b12f72c2ca86a22c.tar.gz dispatch-9c93086c0d4acaa1ed753488b12f72c2ca86a22c.zip | |
feat(notifications): add notifySubagents toggle to suppress subagent turn pings
A parent agent that spawns 8 subagents was producing 9 "Turn complete"
notifications per round — almost always noise. New `notifySubagents`
config flag (defaults to false) gates `turn-completed` and `turn-error`
from any tab with a `parentTabId`. The flag is intentionally NOT applied
to `permission-required` — a subagent's permission prompt still needs a
human tap to proceed, so suppressing it would silently hang the
subagent. `agent-spawned` is already top-level-only by construction.
Wiring:
- core/notifications/types.ts: NtfyConfig.notifySubagents: boolean
- core/notifications/config.ts: defaults to false; normalize() tolerates
missing / wrong-typed values and falls back to false
- core/notifications/dispatcher.ts: new optional TabParentLookup option
(getTabParentId). When notifySubagents=false AND the lookup returns a
non-empty parent id string, turn-completed/turn-error are dropped.
Lookup failures (no lookup configured, throws, returns undefined) fall
back to "treat as top-level" so legitimate top-level events are never
silently dropped when the DB is briefly unreadable.
- api/app.ts: wires getTabParentId via core's getTab(id)?.parentTabId
- frontend SettingsPanel.svelte: "Include subagent tabs" checkbox with
an explanatory hint that permission prompts still fire
Tests (+9):
- 3 in config.test.ts: default-false, explicit-true, wrong-typed fallback
- 6 in dispatcher.test.ts: suppression of turn-completed/turn-error from
subagents, no suppression when flag is true, permission-required not
gated, graceful fallback when lookup is missing/throws/returns undefined
Live ntfy.sh round-trip re-verified (status: 200).
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/notifications/config.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/notifications/dispatcher.ts | 49 | ||||
| -rw-r--r-- | packages/core/src/notifications/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/notifications/types.ts | 8 |
4 files changed, 61 insertions, 0 deletions
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts index 310c606..faa316e 100644 --- a/packages/core/src/notifications/config.ts +++ b/packages/core/src/notifications/config.ts @@ -17,6 +17,7 @@ export function defaultNtfyConfig(): NtfyConfig { topicUrl: "", authToken: "", events: { ...NTFY_DEFAULT_EVENTS }, + notifySubagents: false, }; } @@ -34,6 +35,8 @@ export function normalizeNtfyConfig(raw: unknown): NtfyConfig { topicUrl: typeof obj.topicUrl === "string" ? obj.topicUrl : base.topicUrl, authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, events: { ...base.events }, + notifySubagents: + typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents, }; const rawEvents = obj.events; if (rawEvents && typeof rawEvents === "object") { diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts index 4f4fc79..01ce00c 100644 --- a/packages/core/src/notifications/dispatcher.ts +++ b/packages/core/src/notifications/dispatcher.ts @@ -31,6 +31,15 @@ export interface PermissionPromptSource { /** Look up a human-readable tab title for nicer notification text. */ export type TabTitleLookup = (tabId: string) => string | null; +/** + * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no + * parent) and `undefined` when the lookup can't be performed (no DB, tab + * not found). Both non-strings cause the dispatcher to fall back to + * "treat as top-level" to avoid silently dropping notifications when the + * lookup is broken. + */ +export type TabParentLookup = (tabId: string) => string | null | undefined; + export interface DispatcherOptions { /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */ loadConfig?: () => NtfyConfig; @@ -41,6 +50,13 @@ export interface DispatcherOptions { /** Look up a tab title for richer titles. */ getTabTitle?: TabTitleLookup; /** + * Look up a tab's `parentTabId`. Used to honour the + * `notifySubagents` config flag — when false, `turn-completed` / + * `turn-error` from subagent tabs (those with a parent) are + * suppressed. + */ + getTabParentId?: TabParentLookup; + /** * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit * the whole pending list on every change, so dedupe is essential. */ @@ -51,6 +67,7 @@ export class NotificationDispatcher { private loadConfig: () => NtfyConfig; private send: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>; private getTabTitle: TabTitleLookup | undefined; + private getTabParentId: TabParentLookup | undefined; private dedupeWindowMs: number; /** Recently-sent dedupeKey → expiresAt epoch ms. */ private recentlySent = new Map<string, number>(); @@ -61,6 +78,7 @@ export class NotificationDispatcher { this.send = opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined)); this.getTabTitle = opts.getTabTitle; + this.getTabParentId = opts.getTabParentId; this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000; } @@ -101,12 +119,22 @@ export class NotificationDispatcher { * * `status` events are ignored — they fire on every transition and we'd * either spam or duplicate the `done`/`error` notifications. + * + * Turn events from subagent tabs are suppressed when + * `config.notifySubagents === false` (the default). A parent agent + * spawning 8 subagents would otherwise produce 9 "Turn complete" + * pushes per round; almost always noise. Permission prompts are NOT + * gated this way — a subagent's permission request still needs human + * input to proceed, so suppressing those would silently hang the + * subagent. */ attachToAgentManager(source: AgentEventSource): () => void { const unsub = source.onEvent((event) => { if (event.type === "done") { + if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnCompleted(event)); } else if (event.type === "error") { + if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnError(event)); } else if (event.type === "tab-created") { const ev = event as unknown as { @@ -213,6 +241,27 @@ export class NotificationDispatcher { return `tab ${tabId.slice(0, 8)}`; } + /** + * Returns true when this `tabId` belongs to a subagent AND the user has + * opted out of subagent turn notifications. On lookup failure + * (`getTabParentId` returns `undefined` or throws) we err on the side + * of "not a subagent" — better to over-notify than to silently drop + * legitimate top-level events when the DB is briefly unreadable. + */ + private isSubagentSuppressed(tabId: string): boolean { + const config = this.loadConfig(); + if (config.notifySubagents) return false; + if (!this.getTabParentId) return false; + let parent: string | null | undefined; + try { + parent = this.getTabParentId(tabId); + } catch { + return false; + } + // Only a non-empty string parent id means "this tab is a subagent". + return typeof parent === "string" && parent.length > 0; + } + // ─── Dedupe helpers ─────────────────────────────────────────── private isDuplicate(key: string): boolean { diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts index ea99a58..d9d934d 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -14,6 +14,7 @@ export { type DispatcherOptions, NotificationDispatcher, type PermissionPromptSource, + type TabParentLookup, type TabTitleLookup, } from "./dispatcher.js"; export { type FetchLike, type NtfySendResult, sendNtfy, validateTopicUrl } from "./ntfy.js"; diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index f6baa27..238cb68 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -57,12 +57,20 @@ export interface NotificationEvent { * - `authToken` — optional bearer token for private ntfy servers. * - `events` — per-event-type enable map. Missing entries default to OFF * so a newly-added event type doesn't silently start firing. + * - `notifySubagents` — when false (default), `turn-completed` and + * `turn-error` notifications from subagent tabs (tabs with a + * `parentTabId`) are suppressed. A parent agent that spawns 8 + * subagents would otherwise push 9 "Turn complete" notifications per + * round — usually noise. `permission-required` is NOT gated: even a + * subagent's permission prompt needs a human tap to proceed. + * `agent-spawned` is already top-level-only by construction. */ export interface NtfyConfig { enabled: boolean; topicUrl: string; authToken: string; events: Record<NotificationEventType, boolean>; + notifySubagents: boolean; } /** All event types this build knows about (the source of truth for UI). */ |
