summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 10:12:11 +0900
committerAdam Malczewski <[email protected]>2026-06-01 10:12:11 +0900
commit9c93086c0d4acaa1ed753488b12f72c2ca86a22c (patch)
treee74597029db76d50ea6e48c2bb9bcc62a84fd079
parent41857893e0a3af7afb7b716a2274dc4aec29e61c (diff)
downloaddispatch-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).
-rw-r--r--packages/api/src/app.ts11
-rw-r--r--packages/api/tests/routes.test.ts2
-rw-r--r--packages/core/src/notifications/config.ts3
-rw-r--r--packages/core/src/notifications/dispatcher.ts49
-rw-r--r--packages/core/src/notifications/index.ts1
-rw-r--r--packages/core/src/notifications/types.ts8
-rw-r--r--packages/core/tests/notifications/config.test.ts30
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts138
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte17
9 files changed, 259 insertions, 0 deletions
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 24cef24..0dabb0d 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -24,6 +24,17 @@ export const notificationDispatcher = new NotificationDispatcher({
return null;
}
},
+ getTabParentId: (tabId) => {
+ try {
+ // `undefined` when the lookup fails (tab not found / DB unavailable)
+ // so the dispatcher falls back to "treat as top-level" rather than
+ // silently dropping notifications.
+ const row = getTab(tabId);
+ return row ? row.parentTabId : undefined;
+ } catch {
+ return undefined;
+ }
+ },
});
notificationDispatcher.attachToAgentManager(agentManager);
notificationDispatcher.attachToPermissionManager(permissionManager);
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index c07f932..1fad690 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -290,6 +290,7 @@ vi.mock("@dispatch/core", () => ({
"permission-required": true,
"agent-spawned": false,
},
+ notifySubagents: false,
};
},
saveNtfyConfig() {},
@@ -307,6 +308,7 @@ vi.mock("@dispatch/core", () => ({
"permission-required": true,
"agent-spawned": false,
},
+ notifySubagents: false,
};
},
redactNtfyConfig(c: { authToken?: string }) {
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). */
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
index 64a9637..c110788 100644
--- a/packages/core/tests/notifications/config.test.ts
+++ b/packages/core/tests/notifications/config.test.ts
@@ -34,6 +34,7 @@ describe("defaultNtfyConfig", () => {
expect(cfg.events["turn-error"]).toBe(true);
expect(cfg.events["permission-required"]).toBe(true);
expect(cfg.events["agent-spawned"]).toBe(false);
+ expect(cfg.notifySubagents).toBe(false);
});
});
@@ -72,6 +73,34 @@ describe("normalizeNtfyConfig", () => {
});
});
+describe("normalizeNtfyConfig — notifySubagents", () => {
+ it("defaults notifySubagents to false when absent", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topicUrl: "https://ntfy.sh/x",
+ });
+ expect(normalized.notifySubagents).toBe(false);
+ });
+
+ it("respects an explicit notifySubagents=true", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topicUrl: "https://ntfy.sh/x",
+ notifySubagents: true,
+ });
+ expect(normalized.notifySubagents).toBe(true);
+ });
+
+ it("falls back to default when notifySubagents is wrong-typed", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topicUrl: "https://ntfy.sh/x",
+ notifySubagents: "yes" as unknown,
+ });
+ expect(normalized.notifySubagents).toBe(false);
+ });
+});
+
describe("load/save round-trip", () => {
beforeEach(() => {
fakeSettings.clear();
@@ -92,6 +121,7 @@ describe("load/save round-trip", () => {
"permission-required": true,
"agent-spawned": true,
},
+ notifySubagents: true,
} as const;
saveNtfyConfig({ ...cfg });
const loaded = loadNtfyConfig();
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
index db05de4..750552c 100644
--- a/packages/core/tests/notifications/dispatcher.test.ts
+++ b/packages/core/tests/notifications/dispatcher.test.ts
@@ -25,6 +25,10 @@ function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
"permission-required": true,
"agent-spawned": true,
},
+ // Default to true in the test config so existing tests (which never
+ // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc.
+ // Tests of the new subagent gating override this explicitly.
+ notifySubagents: true,
...overrides,
};
}
@@ -321,3 +325,137 @@ describe("NotificationDispatcher.dispose", () => {
expect(send).not.toHaveBeenCalled();
});
});
+
+describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ const parents = new Map<string, string | null>([
+ ["top-level", null],
+ ["subagent", "top-level"],
+ ]);
+ const getTabParentId = (id: string): string | null | undefined => parents.get(id);
+
+ it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
+ source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
+ });
+
+ it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "error", tabId: "subagent", error: "boom" });
+ source.emit({ type: "error", tabId: "top-level", error: "boom" });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
+ });
+
+ it("still notifies subagents when notifySubagents=true", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: true }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
+ source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(2);
+ });
+
+ it("does NOT gate permission-required (subagents must still get human input)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const psource = makePermissionSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToPermissionManager(psource);
+
+ psource.emit({ id: "p1", permission: "bash", description: "git status" });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required");
+ });
+
+ it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ // intentionally NO getTabParentId
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ // Without a lookup, the dispatcher can't prove this is a subagent; it
+ // must err on the side of notifying so legitimate top-level events
+ // aren't silently dropped.
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to notifying when getTabParentId throws or returns undefined", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId: () => {
+ throw new Error("db unavailable");
+ },
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+
+ const send2 = vi.fn(async () => ({ ok: true }));
+ const source2 = makeAgentSource();
+ const d2 = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send: send2,
+ getTabParentId: () => undefined,
+ });
+ d2.attachToAgentManager(source2);
+ source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send2).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 8031500..08e86ac 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -36,6 +36,7 @@ interface NtfyConfigView {
authToken: string;
hasAuthToken?: boolean;
events: Record<NotificationEventType, boolean>;
+ notifySubagents: boolean;
}
const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
@@ -56,6 +57,7 @@ const DEFAULT_NTFY: NtfyConfigView = {
"permission-required": true,
"agent-spawned": false,
},
+ notifySubagents: false,
};
let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
@@ -162,6 +164,7 @@ async function saveNtfy(): Promise<void> {
enabled: ntfy.enabled,
topicUrl: ntfy.topicUrl,
events: ntfy.events,
+ notifySubagents: ntfy.notifySubagents,
};
if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput;
const res = await fetch(`${apiBase}/notifications`, {
@@ -469,6 +472,20 @@ $effect(() => {
{/each}
</div>
+ <div class="flex flex-col gap-1 mt-1">
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.notifySubagents}
+ />
+ <span class="text-xs text-base-content/70">Include subagent tabs</span>
+ </label>
+ <span class="text-[10px] text-base-content/40 pl-6">
+ Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
+ </span>
+ </div>
+
<div class="flex gap-1 mt-1">
<button
type="button"