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.
- package/package.json +1 -1
- package/skills/faberun/references/operations.md +22 -11
- package/src/cli/plan.mjs +40 -1
- package/src/cli.mjs +1 -0
- package/src/contract/runtime.mjs +50 -2
- package/src/contract/verification.mjs +26 -10
- package/src/engine/assignment.mjs +33 -6
- package/src/engine/backoff.mjs +11 -5
- package/src/engine/cancel.mjs +20 -6
- package/src/engine/failover.mjs +62 -1
- package/src/engine/judge-gate.mjs +63 -5
- package/src/engine/lifecycle.mjs +17 -4
- package/src/engine/mutation.mjs +47 -7
- package/src/engine/resume.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +44 -7
- package/src/engine/scheduler.mjs +19 -23
- package/src/engine/scope.mjs +108 -6
- package/src/engine/settle.mjs +19 -8
- package/src/host/preflight.mjs +5 -3
- package/src/notify/index.mjs +66 -32
- package/src/notify/session.mjs +240 -0
- package/src/plan/pipeline.mjs +215 -22
- package/src/plan/repo-facts.mjs +2 -0
- package/src/plan/routing.mjs +151 -33
- package/src/plan/sizing.mjs +33 -0
- package/src/repo/worktree.mjs +28 -0
- package/src/report/final.mjs +2 -1
- package/src/report/locale.mjs +164 -0
- package/src/report/message.mjs +602 -0
- package/src/report/packet-repetition.mjs +121 -0
- package/src/report/progress.mjs +33 -153
- package/src/report/render.mjs +3 -3
- package/src/seat/tmux.mjs +12 -3
package/src/notify/index.mjs
CHANGED
|
@@ -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/
|
|
5
|
-
* state,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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/
|
|
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/
|
|
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/
|
|
66
|
+
* @type {Promise<typeof import("../report/message.mjs")>|null}
|
|
62
67
|
*/
|
|
63
68
|
let progressModule = null;
|
|
64
|
-
/** @returns {Promise<typeof import("../report/
|
|
69
|
+
/** @returns {Promise<typeof import("../report/message.mjs")>} */
|
|
65
70
|
function loadProgressModule() {
|
|
66
|
-
progressModule ??= import("../report/
|
|
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,
|
|
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
|
-
|
|
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.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
349
|
-
*
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
+
}
|