1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
|
// NotificationDispatcher — turns high-level Dispatch events into
// `sendNtfy(...)` calls, gated by the persisted user config.
//
// The dispatcher is transport-agnostic at the `notify(event)` interface
// boundary: only `sendNtfy` is wired today, but adding another transport
// (email, webhook, etc.) means changing this one file, not the call sites.
//
// All sends are non-blocking (fire-and-forget). A 10-second timeout in
// `sendNtfy` bounds the worst case; the dispatcher additionally guards
// every send in a try/catch so a transport bug can never propagate into
// the agent loop.
import { loadNtfyConfig } from "./config.js";
import { type FetchLike, sendNtfy } from "./ntfy.js";
import type { NotificationEvent, NtfyConfig } from "./types.js";
/** Minimal shape of an `AgentManager`-style event stream we hook into. */
export interface AgentEventSource {
onEvent(
listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void,
): () => void;
}
/** Minimal shape of a `PermissionManager`-style prompt source. */
export interface PermissionPromptSource {
onPromptAdded(
listener: (prompt: { id: string; permission: string; description: string }) => void,
): () => void;
}
/** 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;
/** Override the transport (tests). Defaults to the real `sendNtfy`. */
send?: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
/** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */
fetchImpl?: FetchLike;
/** 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.
*/
dedupeWindowMs?: number;
}
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>();
private unsubs: Array<() => void> = [];
constructor(opts: DispatcherOptions = {}) {
this.loadConfig = opts.loadConfig ?? loadNtfyConfig;
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;
}
/**
* Single internal entry point — every public hook funnels through here.
* Public so a future caller can synthesize an arbitrary notification
* (e.g. a CLI `dispatch notify` command); kept narrow.
*/
notify(event: NotificationEvent): void {
const config = this.loadConfig();
if (!config.enabled) return;
if (!config.events[event.type]) return;
if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return;
if (event.dedupeKey) this.markSent(event.dedupeKey);
// Fire-and-forget: never await, never throw.
try {
void Promise.resolve(this.send(config, event)).catch((err) => {
console.warn(
`[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
);
});
} catch (err) {
// Guard the synchronous portion of `send` too.
console.warn(
`[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Hook into an `AgentManager`-style event stream.
*
* Maps:
* - `done` → `turn-completed`
* - `error` → `turn-error`
* - `tab-created` → `agent-spawned` (only top-level user-agent tabs)
*
* `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 {
tabId: string;
id: string;
title: string;
parentTabId: string | null;
agentSlug?: string | null;
};
// Only notify for top-level user-agent tabs spawned via `summon`.
// Filtering on `agentSlug` skips "blank" new tabs the user opened
// manually, which would be noisy.
if (ev.parentTabId === null && ev.agentSlug) {
this.notify(this.buildAgentSpawned(ev));
}
}
});
this.unsubs.push(unsub);
return unsub;
}
/** Hook into a `PermissionManager`-style prompt source. */
attachToPermissionManager(source: PermissionPromptSource): () => void {
const unsub = source.onPromptAdded((prompt) => {
this.notify(this.buildPermissionRequired(prompt));
});
this.unsubs.push(unsub);
return unsub;
}
/** Release all hooks acquired via `attachTo*`. */
dispose(): void {
for (const u of this.unsubs) {
try {
u();
} catch {
// best-effort
}
}
this.unsubs = [];
this.recentlySent.clear();
}
// ─── Event builders (internal) ────────────────────────────────
private buildTurnCompleted(event: { tabId: string }): NotificationEvent {
const tabLabel = this.tabLabel(event.tabId);
return {
type: "turn-completed",
title: `Turn complete — ${tabLabel}`,
message: `Assistant finished a turn in ${tabLabel}.`,
tabId: event.tabId,
};
}
private buildTurnError(event: {
tabId: string;
error?: unknown;
statusCode?: unknown;
}): NotificationEvent {
const tabLabel = this.tabLabel(event.tabId);
const errText = typeof event.error === "string" ? event.error : "Unknown error";
const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : "";
return {
type: "turn-error",
title: `Turn failed — ${tabLabel}`,
message: `${errText}${statusText}`,
tabId: event.tabId,
};
}
private buildPermissionRequired(prompt: {
id: string;
permission: string;
description: string;
}): NotificationEvent {
return {
type: "permission-required",
title: `Permission required: ${prompt.permission}`,
message: prompt.description || `Agent is requesting ${prompt.permission} permission.`,
// Permission prompts can re-emit (e.g. another prompt arrives while
// this one is still pending) — dedupe on the prompt id.
dedupeKey: `permission:${prompt.id}`,
};
}
private buildAgentSpawned(ev: {
tabId: string;
id: string;
title: string;
agentSlug?: string | null;
}): NotificationEvent {
return {
type: "agent-spawned",
title: `User agent spawned — ${ev.agentSlug ?? "agent"}`,
message: ev.title,
tabId: ev.tabId ?? ev.id,
};
}
private tabLabel(tabId: string): string {
const title = this.getTabTitle?.(tabId);
if (title?.trim()) return title.trim();
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 {
const expires = this.recentlySent.get(key);
if (expires === undefined) return false;
if (expires <= Date.now()) {
this.recentlySent.delete(key);
return false;
}
return true;
}
private markSent(key: string): void {
// Lazy-evict expired entries when the map gets large.
if (this.recentlySent.size > 256) {
const now = Date.now();
for (const [k, exp] of this.recentlySent) {
if (exp <= now) this.recentlySent.delete(k);
}
}
this.recentlySent.set(key, Date.now() + this.dedupeWindowMs);
}
}
|