faberun 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Direct notification dispatcher (TECH-SPEC lean, rule 6). On `node.terminal`,
3
3
  * `run.terminal` and `attention` the controller renders the message through
4
- * `renderRunProgress` (`report/progress.mjs`) from the run's own persisted
5
- * state, calls `FABERUN_NOTIFY_BIN` with the event as JSON on stdin, and
6
- * appends a receipt (`delivered`, `failed` or
7
- * `no_transport`, with the timestamp) to `<run-dir>/notify.jsonl`. Delivery is
8
- * lossy: an event is attempted once, a failure schedules no further attempt and
9
- * is never requeued, and the controller never waits on a retry it will not
10
- * make. The next read of the run's own artefacts carries the full state. With
11
- * no transport bound (`FABERUN_NOTIFY_BIN` unset) nothing is spawned and
12
- * a `no_transport` receipt is recorded instead — there is no implicit desktop
4
+ * `renderRunProgress` (`report/message.mjs`) from the run's own persisted
5
+ * state, delivers that one text to every bound transport at once, and appends
6
+ * one receipt (`delivered`, `failed` or `no_transport`, with the timestamp
7
+ * and one entry per transport) to `<run-dir>/notify.jsonl`. Two transports
8
+ * exist, additive and independently opted in: `FABERUN_NOTIFY_BIN`, an
9
+ * executable called with the event as JSON on stdin (a phone, a chat), and
10
+ * `FABERUN_NOTIFY_SESSION`, the harness session the controller was launched
11
+ * from (`session.mjs`), which is what wakes the operator's seat. Delivery is
12
+ * lossy: an event is attempted once per transport, a failure schedules no
13
+ * further attempt and is never requeued, and the controller never waits on a
14
+ * retry it will not make. The next read of the run's own artefacts carries
15
+ * the full state. With no transport bound nothing is spawned and a
16
+ * `no_transport` receipt is recorded instead — there is no implicit desktop
13
17
  * fallback. The macOS notifier is reachable only by setting
14
18
  * `FABERUN_NOTIFY_BIN=os-macos`, an explicit opt-in, never a default.
15
19
  *
@@ -46,24 +50,25 @@ import { createHash } from "node:crypto";
46
50
  import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
47
51
  import { join } from "node:path";
48
52
  import { createMacosNotifier } from "./os-macos.mjs";
53
+ import { deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
49
54
  import { errorMessage } from "../util.mjs";
50
55
 
51
56
  /**
52
- * `report/progress.mjs` reaches back to this module (through
57
+ * `report/message.mjs` reaches back to this module (through
53
58
  * `run/node-store.mjs` -> `run/disk-gc.mjs` -> `host/preflight.mjs`, which
54
59
  * reads `NOTIFY_BIN_ENV`), so a static top-level import of it here would be a
55
60
  * real cycle: `host/preflight.mjs` would read `NOTIFY_BIN_ENV` while this
56
61
  * module's own top level was still mid-evaluation, before the `const` is
57
62
  * assigned. A dynamic import resolves this module first and defers loading
58
- * `report/progress.mjs` until the first call, by which point this module has
63
+ * `report/message.mjs` until the first call, by which point this module has
59
64
  * already finished initializing -- so the cycle is real but harmless. Cached
60
65
  * after the first call so every subsequent render reuses the same module.
61
- * @type {Promise<typeof import("../report/progress.mjs")>|null}
66
+ * @type {Promise<typeof import("../report/message.mjs")>|null}
62
67
  */
63
68
  let progressModule = null;
64
- /** @returns {Promise<typeof import("../report/progress.mjs")>} */
69
+ /** @returns {Promise<typeof import("../report/message.mjs")>} */
65
70
  function loadProgressModule() {
66
- progressModule ??= import("../report/progress.mjs");
71
+ progressModule ??= import("../report/message.mjs");
67
72
  return progressModule;
68
73
  }
69
74
 
@@ -129,7 +134,8 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
129
134
  /** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
130
135
  /** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
131
136
  /** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
132
- /** @typedef {{ok: boolean, error?: string, noTransport?: boolean}} DeliveryResult */
137
+ /** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
138
+ /** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
133
139
 
134
140
  /**
135
141
  * Render the message for an event. For `node.terminal`, `run.terminal` and
@@ -250,25 +256,31 @@ function truncate(value) {
250
256
  * @returns {string|null}
251
257
  */
252
258
  export function noTransportWarning(env = process.env) {
253
- return env[NOTIFY_BIN_ENV] ? null : NOTIFY_NO_TRANSPORT_WARNING;
259
+ if (env[NOTIFY_BIN_ENV]) return null;
260
+ if (resolveSessionTargets(env).length) return null;
261
+ return NOTIFY_NO_TRANSPORT_WARNING;
254
262
  }
255
263
 
256
264
  /**
257
- * What `campaign watch --wake` must say about waking. No adapter declares
258
- * `canWake: true` (`os-macos` is `canWake: false`), so the wake verb records
259
- * to the inbox and the managed block and never implies a session was woken.
265
+ * What `campaign watch --wake` must say about waking. The external transport
266
+ * never wakes anything (`os-macos` and every `FABERUN_NOTIFY_BIN` executable
267
+ * are `canWake: false`: they push to a person); the session transports are
268
+ * the only `canWake: true`, and the notice says which of them the environment
269
+ * resolves to, so the verb never implies a session was woken when none will be.
260
270
  *
261
271
  * @param {string|undefined} [bin]
272
+ * @param {NodeJS.ProcessEnv} [env]
262
273
  * @returns {string}
263
274
  */
264
- export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV]) {
275
+ export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV], env = process.env) {
276
+ const session = sessionWakeNotice(env);
265
277
  if (!bin) {
266
- return "no notify transport is configured; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; no session is woken";
278
+ return `no external notify transport is configured; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
267
279
  }
268
280
  if (bin === MACOS_TRANSPORT) {
269
- return "os-macos cannot wake a session (canWake: false); --wake records to .runs/inbox.jsonl and the AGENTS.md managed block";
281
+ return `os-macos cannot wake a session (canWake: false); --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
270
282
  }
271
- return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; no session is woken`;
283
+ return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
272
284
  }
273
285
 
274
286
  /** @param {string} runsDir @returns {string} */
@@ -345,20 +357,39 @@ export function appendInbox(runsDir, event) {
345
357
  }
346
358
 
347
359
  /**
348
- * Deliver one event through the bound transport. No transport bound resolves
349
- * `{ok: false, noTransport: true}` without spawning anything.
360
+ * Deliver one event through every bound transport at once: the external
361
+ * executable and each resolved harness session, all given the same rendered
362
+ * text. The result is `ok` when any one of them took the message, carries
363
+ * every transport's own outcome for the receipt, and is
364
+ * `{ok: false, noTransport: true}` when nothing at all is bound -- without
365
+ * spawning or connecting anything.
350
366
  *
351
367
  * @param {{type: string, summary: string, campaignId?: string|null, [key: string]: unknown}} event
352
- * @param {{bin?: string, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
368
+ * @param {{bin?: string, env?: NodeJS.ProcessEnv, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
353
369
  * @returns {Promise<DeliveryResult>}
354
370
  */
355
- function deliverNotification(event, options = {}) {
356
- const bin = options.bin ?? process.env[NOTIFY_BIN_ENV];
357
- if (!bin) return Promise.resolve({ ok: false, noTransport: true });
358
- if (bin === MACOS_TRANSPORT) {
359
- return createMacosNotifier({ spawn: options.spawn }).deliver(/** @type {any} */ (event));
371
+ async function deliverNotification(event, options = {}) {
372
+ const env = options.env ?? process.env;
373
+ const bin = options.bin ?? env[NOTIFY_BIN_ENV];
374
+ const targets = resolveSessionTargets(env);
375
+ /** @type {Promise<TransportOutcome>[]} */
376
+ const attempts = [];
377
+ if (bin) {
378
+ const external = bin === MACOS_TRANSPORT
379
+ ? createMacosNotifier({ spawn: options.spawn }).deliver(/** @type {any} */ (event))
380
+ : spawnDeliver(bin, event, options);
381
+ attempts.push(external.then((result) => ({ id: bin === MACOS_TRANSPORT ? MACOS_TRANSPORT : "bin", ...result })));
360
382
  }
361
- return spawnDeliver(bin, event, options);
383
+ /** @type {Promise<TransportOutcome[]>} */
384
+ const sessions = targets.length ? deliverToSessions(event, targets, { timeoutMs: options.timeoutMs, env }) : Promise.resolve([]);
385
+ if (!attempts.length && !targets.length) return { ok: false, noTransport: true, transports: [] };
386
+ const transports = [...(await Promise.all(attempts)), ...(await sessions)];
387
+ const failures = transports.filter((outcome) => !outcome.ok).map((outcome) => `${outcome.id}: ${outcome.error ?? "failed"}`);
388
+ return {
389
+ ok: transports.some((outcome) => outcome.ok),
390
+ ...(failures.length ? { error: failures.join("; ") } : {}),
391
+ transports,
392
+ };
362
393
  }
363
394
 
364
395
  /**
@@ -472,6 +503,9 @@ export class NotifyQueue {
472
503
  summary,
473
504
  attempt: 1,
474
505
  status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
506
+ // One entry per bound transport, so a receipt that says `delivered`
507
+ // also says whether the phone, the session, or both took the message.
508
+ transports: result.transports ?? [],
475
509
  at: new Date(this.now()).toISOString(),
476
510
  };
477
511
  if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The session transports: the channel that wakes the harness session an
3
+ * operator is sitting in, rather than a phone.
4
+ *
5
+ * A controller launched from inside a Claude Code or Codex session inherits
6
+ * that session's own address. Claude Code exports `CLAUDE_CODE_MESSAGING_SOCKET`
7
+ * (a Unix socket, the session's inbox) and `CLAUDE_CODE_MESSAGING_TOKEN` to
8
+ * every Bash command and hook it runs; Codex exports `CODEX_THREAD_ID` and
9
+ * accepts `codex queue --thread <id> --message <text>` for a running thread.
10
+ * Posting the rendered message there is what `canWake: true` means: the
11
+ * session gets a turn and reads the same text the phone gets, so the two
12
+ * never differ. It is a module apart from `index.mjs` because that module is
13
+ * the dispatcher and the receipt log; this one knows two wire protocols and
14
+ * nothing about receipts.
15
+ *
16
+ * Measured 2026-09-21 against a live Claude Code 2.1.269 session: the inbox
17
+ * accepts `{"type":"auth","token"}` and then
18
+ * `{"type":"user","message":{"role":"user","content"}}`, one JSON object per
19
+ * line, answers nothing, and delivered the line to the session mid-turn; the
20
+ * session closes a connection that has not sent a complete line within 30 s.
21
+ * The token is what lets the session verify a poster whose process has
22
+ * already exited (macOS cannot check process ancestry after exit), so it is
23
+ * always sent when present and never logged.
24
+ *
25
+ * Opt-in by `FABERUN_NOTIFY_SESSION`, never by detection alone: a test suite
26
+ * running inside a session would otherwise wake it on every fixture's
27
+ * terminal node, the hazard `FABERUN_NOTIFY_BIN` already has and guards
28
+ * against. The seat sets the variable to `auto` on the window it launches, so
29
+ * everything under a seat inherits the opt-in and nothing else does.
30
+ */
31
+ import { spawn as defaultSpawn } from "node:child_process";
32
+ import { createConnection as defaultConnect } from "node:net";
33
+ import { errorMessage } from "../util.mjs";
34
+
35
+ export const NOTIFY_SESSION_ENV = "FABERUN_NOTIFY_SESSION";
36
+ export const CLAUDE_SOCKET_ENV = "CLAUDE_CODE_MESSAGING_SOCKET";
37
+ export const CLAUDE_TOKEN_ENV = "CLAUDE_CODE_MESSAGING_TOKEN";
38
+ export const CODEX_THREAD_ENV = "CODEX_THREAD_ID";
39
+
40
+ /**
41
+ * Measured 2026-09-21: one post to a live inbox completed well under a
42
+ * second, and Claude Code's own peer sender gives up at 5 s; the same budget
43
+ * `index.mjs` gives an external transport.
44
+ */
45
+ export const SESSION_DELIVERY_TIMEOUT_MS = 5_000;
46
+
47
+ /** The values the variable accepts besides `codex:<thread>`. */
48
+ const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
49
+
50
+ /** @typedef {Record<string, unknown>} JsonObject */
51
+ /** @typedef {{kind: "claude", id: "claude-session", socketPath: string, token: string|null}} ClaudeTarget */
52
+ /** @typedef {{kind: "codex", id: "codex-session", thread: string}} CodexTarget */
53
+ /** @typedef {ClaudeTarget|CodexTarget} SessionTarget */
54
+ /** @typedef {{type: string, summary?: unknown, campaignId?: unknown, runId?: unknown, nodeId?: unknown}} SessionEvent */
55
+ /** @typedef {{ok: boolean, error?: string}} SessionDelivery */
56
+ /** @typedef {{once(event: string, listener: (...args: any[]) => void): unknown, kill(signal?: any): unknown, stderr?: {on(event: string, listener: (chunk: string|Buffer) => void): unknown}|null}} SpawnedChild */
57
+ /** @typedef {(command: string, args?: any, options?: any) => SpawnedChild} SpawnFunction */
58
+ /** @typedef {{once(event: string, listener: (...args: any[]) => void): unknown, end(data: string, callback?: () => void): unknown, destroy(): unknown}} SessionSocket */
59
+ /** @typedef {(path: string) => SessionSocket} ConnectFunction */
60
+
61
+ /**
62
+ * The sessions the variable and the environment together name. `auto` takes
63
+ * every session whose address is present -- a Codex thread opened from a
64
+ * Claude Code shell inherits both, and both are supervising. An explicit
65
+ * `claude` or `codex` whose address is absent resolves to nothing; the
66
+ * doctor reports why through `sessionSettingProblem`, this function never
67
+ * throws, because it backs a lossy dispatcher.
68
+ *
69
+ * @param {NodeJS.ProcessEnv} [env]
70
+ * @returns {SessionTarget[]}
71
+ */
72
+ export function resolveSessionTargets(env = process.env) {
73
+ const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
74
+ if (!setting || setting === "off") return [];
75
+ /** @type {SessionTarget[]} */
76
+ const targets = [];
77
+ const socketPath = env[CLAUDE_SOCKET_ENV];
78
+ if ((setting === "auto" || setting === "claude") && socketPath) {
79
+ targets.push({ kind: "claude", id: "claude-session", socketPath, token: env[CLAUDE_TOKEN_ENV] || null });
80
+ }
81
+ const thread = setting.startsWith("codex:") ? setting.slice("codex:".length).trim() : env[CODEX_THREAD_ENV];
82
+ if ((setting === "auto" || setting === "codex" || setting.startsWith("codex:")) && thread) {
83
+ targets.push({ kind: "codex", id: "codex-session", thread });
84
+ }
85
+ return targets;
86
+ }
87
+
88
+ /**
89
+ * Why the setting names no session, in one sentence for `doctor` and
90
+ * `--wake`; `null` when it is unset, `off`, or resolves to at least one.
91
+ *
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {string|null}
94
+ */
95
+ export function sessionSettingProblem(env = process.env) {
96
+ const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
97
+ if (!setting || setting === "off") return null;
98
+ if (!SETTINGS.has(setting) && !setting.startsWith("codex:")) {
99
+ return `${NOTIFY_SESSION_ENV}=${setting} is not one of off, auto, claude, codex, codex:<thread>`;
100
+ }
101
+ if (resolveSessionTargets(env).length) return null;
102
+ if (setting === "claude") return `${NOTIFY_SESSION_ENV}=claude but ${CLAUDE_SOCKET_ENV} is not set: this process was not started from inside a Claude Code session`;
103
+ if (setting === "codex") return `${NOTIFY_SESSION_ENV}=codex but ${CODEX_THREAD_ENV} is not set: this process was not started from inside a Codex session`;
104
+ if (setting.startsWith("codex:")) return `${NOTIFY_SESSION_ENV}=codex: names an empty thread id`;
105
+ return `${NOTIFY_SESSION_ENV}=auto found neither ${CLAUDE_SOCKET_ENV} nor ${CODEX_THREAD_ENV}: no harness session to wake`;
106
+ }
107
+
108
+ /**
109
+ * What `campaign watch --wake` and `doctor` say about waking a session.
110
+ *
111
+ * @param {NodeJS.ProcessEnv} [env]
112
+ * @returns {string}
113
+ */
114
+ export function sessionWakeNotice(env = process.env) {
115
+ const targets = resolveSessionTargets(env);
116
+ if (targets.length) {
117
+ return `session transport ${targets.map((target) => target.id).join(" + ")} wakes the launching harness session (canWake: true)`;
118
+ }
119
+ const problem = sessionSettingProblem(env);
120
+ if (problem) return `${problem}; no harness session is woken`;
121
+ return `${NOTIFY_SESSION_ENV} unset: no harness session is woken (a seat window sets it to auto)`;
122
+ }
123
+
124
+ /**
125
+ * The text a session reads: the rendered summary, whose first line already
126
+ * names the sender; a counters-only fallback for an event that reaches a
127
+ * transport unrendered.
128
+ *
129
+ * @param {SessionEvent} event
130
+ * @returns {string}
131
+ */
132
+ function messageText(event) {
133
+ if (typeof event.summary === "string" && event.summary.trim()) return event.summary;
134
+ return ["🐦 faberun", event.type, event.campaignId, event.runId, event.nodeId].filter((part) => typeof part === "string" && part).join(" · ");
135
+ }
136
+
137
+ /**
138
+ * @param {{connect?: ConnectFunction, timeoutMs?: number}} [options]
139
+ * @returns {{id: "claude-session", capabilities: {canPush: boolean, canWake: boolean, canRenderAmbient: boolean}, deliver(event: SessionEvent, target: ClaudeTarget): Promise<SessionDelivery>}}
140
+ */
141
+ export function createClaudeSessionNotifier({ connect = /** @type {ConnectFunction} */ (defaultConnect), timeoutMs = SESSION_DELIVERY_TIMEOUT_MS } = {}) {
142
+ return {
143
+ id: "claude-session",
144
+ capabilities: { canPush: false, canWake: true, canRenderAmbient: false },
145
+ deliver(event, target) {
146
+ return new Promise((resolve) => {
147
+ /** @type {string[]} */
148
+ const lines = [];
149
+ if (target.token) lines.push(JSON.stringify({ type: "auth", token: target.token }));
150
+ lines.push(JSON.stringify({ type: "user", message: { role: "user", content: messageText(event) } }));
151
+ let socket;
152
+ try {
153
+ socket = connect(target.socketPath);
154
+ } catch (error) {
155
+ resolve({ ok: false, error: errorMessage(error) });
156
+ return;
157
+ }
158
+ let settled = false;
159
+ /** @param {SessionDelivery} result */
160
+ const finish = (result) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ resolve(result);
165
+ };
166
+ const timer = setTimeout(() => {
167
+ socket.destroy();
168
+ finish({ ok: false, error: `session inbox ${target.socketPath} did not take the message within ${timeoutMs}ms` });
169
+ }, timeoutMs);
170
+ socket.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
171
+ // The inbox answers nothing, so a flushed write and a clean close is
172
+ // the whole evidence of delivery there is.
173
+ socket.once("connect", () => socket.end(`${lines.join("\n")}\n`, () => finish({ ok: true })));
174
+ });
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * @param {{spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
181
+ * @returns {{id: "codex-session", capabilities: {canPush: boolean, canWake: boolean, canRenderAmbient: boolean}, deliver(event: SessionEvent, target: CodexTarget): Promise<SessionDelivery>}}
182
+ */
183
+ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} */ (defaultSpawn), timeoutMs = SESSION_DELIVERY_TIMEOUT_MS, env = process.env } = {}) {
184
+ return {
185
+ id: "codex-session",
186
+ capabilities: { canPush: false, canWake: true, canRenderAmbient: false },
187
+ deliver(event, target) {
188
+ return new Promise((resolve) => {
189
+ const executable = env.FABERUN_CODEX_BIN ?? "codex";
190
+ let child;
191
+ try {
192
+ child = spawn(executable, ["queue", "--thread", target.thread, "--message", messageText(event)], { stdio: ["ignore", "ignore", "pipe"], env });
193
+ } catch (error) {
194
+ resolve({ ok: false, error: errorMessage(error) });
195
+ return;
196
+ }
197
+ let settled = false;
198
+ let stderr = "";
199
+ /** @param {SessionDelivery} result */
200
+ const finish = (result) => {
201
+ if (settled) return;
202
+ settled = true;
203
+ clearTimeout(timer);
204
+ resolve(result);
205
+ };
206
+ const timer = setTimeout(() => {
207
+ try {
208
+ child.kill("SIGTERM");
209
+ } catch {
210
+ // ESRCH: the child already exited before the timeout kill; finish still resolves.
211
+ }
212
+ finish({ ok: false, error: `codex queue did not return within ${timeoutMs}ms` });
213
+ }, timeoutMs);
214
+ child.stderr?.on("data", (chunk) => {
215
+ stderr = `${stderr}${chunk}`.slice(-1024);
216
+ });
217
+ child.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
218
+ child.once("exit", (code) => finish(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `codex queue exited ${code}` }));
219
+ });
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Deliver one event to every resolved session, each through its own adapter,
226
+ * and report per target: the receipt names which session took the message.
227
+ *
228
+ * @param {SessionEvent} event
229
+ * @param {SessionTarget[]} targets
230
+ * @param {{connect?: ConnectFunction, spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
231
+ * @returns {Promise<{id: string, ok: boolean, error?: string}[]>}
232
+ */
233
+ export function deliverToSessions(event, targets, options = {}) {
234
+ return Promise.all(targets.map(async (target) => {
235
+ const result = target.kind === "claude"
236
+ ? await createClaudeSessionNotifier(options).deliver(event, target)
237
+ : await createCodexSessionNotifier(options).deliver(event, target);
238
+ return { id: target.id, ...result };
239
+ }));
240
+ }