diff options
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). */ |
