faberun 0.15.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -109,18 +109,29 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
109
109
 
110
110
  ## Notify
111
111
 
112
- On `node.terminal`, `run.terminal` and `attention` the controller renders a
113
- one-line message from counters and identifiers only (node id, run id, state,
114
- attempt, error code, done/total never model text), calls the executable named
115
- by `FABERUN_NOTIFY_BIN` with that event as JSON on stdin, and appends a
116
- timestamped receipt (`delivered`, `failed`, `no_transport`) to
112
+ On `node.terminal`, `run.terminal` and `attention` the controller renders one
113
+ message from persisted state, in the operator's own language (detected from
114
+ the campaign goal, journal notes and node objectives; English otherwise):
115
+ line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
116
+ · 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
117
+ for a node, what every node delivered for a run, or why / asked / do for
118
+ attention, then a progress bar, a rule and the `🐦 faberun` signature with
119
+ campaign percent, cost and elapsed; an `⬆️` line names a newer release when
120
+ the cached update check has one. ≤2 KiB. It delivers that
121
+ same text to every bound transport at once, appending one receipt
122
+ (`delivered`, `failed`, `no_transport`, one entry per transport) to
117
123
  `<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
118
- no backoff. Unset, nothing is spawned and the receipt is `no_transport`.
119
- `FABERUN_NOTIFY_BIN=os-macos` selects the bundled `osascript` adapter
120
- (`canWake: false`); any other value is an executable path. A resume never
121
- re-sends a notification already recorded for the same node, attempt and outcome.
122
- No transport is a default: `doctor`, `preflight` and the foreground launch warn
123
- when the variable is empty, and `--wake` reports no adapter can wake a session.
124
+ no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
125
+ JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
126
+ a person, `canWake: false`. `FABERUN_NOTIFY_SESSION=auto` wakes the harness
127
+ session the controller was launched from, `canWake: true`: the Claude Code
128
+ inbox socket and the Codex thread the environment names. A seat window sets it;
129
+ nothing else does, so a test suite never wakes a session. **On an inbound
130
+ `🐦 faberun` message**: `✅` or `🏁` with nothing waiting, answer in one line
131
+ and keep waiting; `👀`, act on the `do` command it names. A
132
+ resume never re-sends a notification already recorded for the same node, attempt
133
+ and outcome. No transport is a default: `doctor`, `preflight` and the foreground
134
+ launch warn when both variables are empty, and `--wake` names what will wake.
124
135
  Campaign-level lines are queued in `.runs/inbox.jsonl`, the managed block's
125
136
  append-only record — one object per line `{schemaVersion, eventId, at, type,
126
137
  campaignId, runId, nodeId, status, errorCode, dedupeKey, summary}`, deduped on
@@ -17,7 +17,7 @@ import { terminateInvocation } from "./process.mjs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { readFileSync } from "node:fs";
19
19
  import { readRunNodes } from "./scheduler.mjs";
20
- import { deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
20
+ import { createPreservedRef, deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
21
21
  import { syncAgentSignal } from "../repo/signal.mjs";
22
22
  import { transition, writeNode } from "./state.mjs";
23
23
  import { validateContract } from "../contract/index.mjs";
@@ -26,10 +26,11 @@ import { writeJsonAtomic } from "../run/store.mjs";
26
26
  /** @typedef {import("./process.mjs").InvocationProbe} InvocationProbe */
27
27
  /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
28
28
  /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
29
+ /** @typedef {{preservedRefs: string[], released: string[]}} CancelResult */
29
30
 
30
31
  /**
31
32
  * @param {string} runDirPath
32
- * @returns {Promise<boolean>}
33
+ * @returns {Promise<CancelResult>}
33
34
  */
34
35
  export async function cancelRun(runDirPath) {
35
36
  const runDir = resolve(runDirPath);
@@ -108,20 +109,33 @@ export async function cancelRun(runDirPath) {
108
109
  throw error;
109
110
  }
110
111
  if (!await waitForTerminal(runDir, 1_000)) throw new Error("cancel could not confirm a terminal run state");
112
+ // Preserved refs come first, before anything is released: a cancel that
113
+ // dies part-way must leave more work reachable, never less. If a creation
114
+ // fails here, nothing below has run and every integrated commit is as
115
+ // reachable as cancel found it. A node whose integratedHead is null was
116
+ // never integrated and gets none.
117
+ const preservedRefs = states
118
+ .filter((state) => state.integratedHead)
119
+ .map((state) => createPreservedRef(contract.cwd, contract.id, state.id, state.integratedHead));
111
120
  // The run directory is evidence a campaign ledger may still want, so it
112
121
  // stays; the run ref and every node's attempt branch are just git names
113
122
  // the next launch of this same contract id needs back, and cancel is the
114
123
  // operator saying this run is over. Releasing a name is not destroying a
115
- // record -- each state's `worktree.branch`/`commit` fields, and the sha
116
- // this ref pointed at, remain in the persisted snapshot regardless.
124
+ // record: the sha remains in the persisted snapshot, and the commit it
125
+ // names stays reachable through the preserved ref created above.
117
126
  // Idempotent both ways: `removeWorktree` and `deleteRef` already tolerate
118
127
  // an artefact a previous cancel (or the run itself) already released.
128
+ const released = [];
119
129
  for (const state of states) {
120
- if (state.worktree?.branch) releaseAttemptWorktree(contract.cwd, state.worktree.path, state.worktree.branch);
130
+ if (!state.worktree?.branch) continue;
131
+ releaseAttemptWorktree(contract.cwd, state.worktree.path, state.worktree.branch);
132
+ released.push(`refs/heads/${state.worktree.branch}`);
133
+ if (state.worktree.path) released.push(state.worktree.path);
121
134
  }
122
135
  deleteRef(contract.cwd, runRefName(contract.id));
136
+ released.push(runRefName(contract.id));
123
137
  syncAgentSignal(join(runDir, ".."));
124
- return true;
138
+ return { preservedRefs, released };
125
139
  } finally {
126
140
  controllerLock.release();
127
141
  }
@@ -27,6 +27,7 @@ import { errorMessage } from "../util.mjs";
27
27
  import { boundedGitSync } from "../repo/worktree.mjs";
28
28
  import { routeRuntime } from "../contract/runtime.mjs";
29
29
  import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
30
+ import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
30
31
  import { findExecutable } from "./platform.mjs";
31
32
  import { colorLevel, statusToken } from "../cli/brand.mjs";
32
33
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
@@ -209,9 +210,9 @@ export function environmentPreflight(options) {
209
210
  */
210
211
  export function notifyTransportCheck(env = process.env) {
211
212
  const warning = noTransportWarning(env);
212
- return warning
213
- ? fail("notify transport", warning, true)
214
- : pass("notify transport", `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}`);
213
+ if (warning) return fail("notify transport", warning, true);
214
+ const external = env[NOTIFY_BIN_ENV] ? `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}` : `${NOTIFY_BIN_ENV} unset`;
215
+ return pass("notify transport", `${external} · ${sessionWakeNotice(env)}`);
215
216
  }
216
217
 
217
218
  /** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
@@ -265,6 +266,7 @@ export function declaredVerificationCommands(contract) {
265
266
  */
266
267
  const SIDE_EFFECT_ENV_KEYS = [
267
268
  NOTIFY_BIN_ENV, // a measurement must not notify a human
269
+ NOTIFY_SESSION_ENV, // nor wake the harness session it was measured from
268
270
  "FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
269
271
  "FABERUN_CLAUDE_BIN", // same, for the claude harness
270
272
  "FABERUN_AGY_BIN", // same, for the agy harness
@@ -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
+ }
@@ -607,6 +607,17 @@ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
607
607
  * not make — a removed node's writes were reviewed as a removal, not as a
608
608
  * silent shrink.
609
609
  *
610
+ * Membership is judged against the whole revised plan, not against the node
611
+ * that used to hold the path. A revise that splits one node in two and hands
612
+ * a file to the new sibling has not dropped that file: it is still declared,
613
+ * still reviewable, and the graph change is visible in the plan. Measured
614
+ * 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
615
+ * exactly that move a critical and contested a sound plan — the draft's only
616
+ * node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
617
+ * layered them into worktree-preserve-ref-verb and
618
+ * cancel-preserves-integrated-heads, which is the decomposition this
619
+ * repository's own layering asks for.
620
+ *
610
621
  * @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
611
622
  * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
612
623
  * @returns {PlanFindingOutput[]}
@@ -614,6 +625,7 @@ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
614
625
  export function droppedWriteFindings(previousPlan, revisedPlan) {
615
626
  if (!previousPlan || !revisedPlan) return [];
616
627
  const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
628
+ const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
617
629
  /** @type {PlanFindingOutput[]} */
618
630
  const findings = [];
619
631
  for (const node of revisedPlan.nodes) {
@@ -621,13 +633,13 @@ export function droppedWriteFindings(previousPlan, revisedPlan) {
621
633
  if (!previousWrites) continue;
622
634
  let dropped = 0;
623
635
  for (const path of previousWrites) {
624
- if (node.writeFiles.includes(path)) continue;
636
+ if (stillDeclared.has(path)) continue;
625
637
  dropped += 1;
626
638
  findings.push({
627
639
  id: `dropped-write-${node.id}-${dropped}`,
628
640
  severity: "critical",
629
641
  nodeId: node.id,
630
- text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare. Declare it again: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work.`,
642
+ text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
631
643
  });
632
644
  }
633
645
  }
@@ -19,6 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { timeVerificationCommands } from "../host/preflight.mjs";
21
21
  import { NOTIFY_BIN_ENV } from "../notify/index.mjs";
22
+ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
22
23
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
23
24
 
24
25
  /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
@@ -45,6 +46,7 @@ const MEASURE_OUTPUT_CAP_BYTES = 4096;
45
46
  */
46
47
  const MEASURE_SIDE_EFFECT_ENV_KEYS = [
47
48
  NOTIFY_BIN_ENV,
49
+ NOTIFY_SESSION_ENV,
48
50
  "FABERUN_CODEX_BIN",
49
51
  "FABERUN_CLAUDE_BIN",
50
52
  "FABERUN_AGY_BIN",