faberun 0.15.0 → 0.17.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.17.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": {
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
- "test": "node --test test/*.test.mjs test/*/*.test.mjs",
29
+ "test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
30
30
  "docs": "node src/cli/manual.mjs --write",
31
31
  "docs:check": "node src/cli/manual.mjs --check",
32
32
  "prepare": "husky"
@@ -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
@@ -8,7 +8,7 @@
8
8
  * the journal's own `eventId` so two sessions cannot consume each other's place.
9
9
  */
10
10
  import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WATCH_CURSOR_SCHEMA_VERSION } from "./layout.mjs";
11
- import { boundedText, collapseLines } from "../util.mjs";
11
+ import { collapseLines } from "../util.mjs";
12
12
  import { campaignIdOf } from "./record.mjs";
13
13
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { randomUUID } from "node:crypto";
@@ -382,6 +382,11 @@ function normalizeEntry(entry) {
382
382
  return /** @type {JournalEntry} */ (normalized);
383
383
  }
384
384
  /**
385
+ * Collapse to one line and enforce the byte cap by refusing, not truncating:
386
+ * the journal is the record of what was written, so a note that does not fit
387
+ * is the author's to cut -- a silently shortened entry lies about its own
388
+ * write. Readers of already-stored text never pass through here.
389
+ *
385
390
  * @param {unknown} value
386
391
  * @param {string} label
387
392
  * @param {number} maxBytes
@@ -391,5 +396,9 @@ export function normalizeText(value, label, maxBytes = JOURNAL_TEXT_BYTES) {
391
396
  requireText(value, label);
392
397
  const collapsed = collapseLines(value);
393
398
  if (!collapsed) throw new TypeError(`${label} must not be blank`);
394
- return boundedText(collapsed, maxBytes);
399
+ const bytes = Buffer.byteLength(collapsed, "utf8");
400
+ if (bytes > maxBytes) {
401
+ throw new TypeError(`${label} is ${bytes} bytes, over the ${maxBytes}-byte cap; cut ${bytes - maxBytes} bytes and retry`);
402
+ }
403
+ return collapsed;
395
404
  }
@@ -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
  }
@@ -30,6 +30,7 @@
30
30
  import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writeSync } from "node:fs";
31
31
  import { dirname } from "node:path";
32
32
  import { spawn } from "node:child_process";
33
+ import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
33
34
 
34
35
  /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
35
36
 
@@ -155,9 +156,13 @@ function childEnv() {
155
156
  if (value === null) delete merged[key];
156
157
  else merged[key] = value;
157
158
  }
158
- // Worker providers are not a notification surface: strip the controller-only
159
- // transport after the harness overlay so no harness can reintroduce it.
160
- delete merged.FABERUN_NOTIFY_BIN;
159
+ // Worker providers are not a notification surface: strip every controller-only
160
+ // transport after the harness overlay so no harness can reintroduce one. The
161
+ // list lives in `notify/index.mjs`, not here: this line once named
162
+ // FABERUN_NOTIFY_BIN alone, and on 2026-09-21 a worker that inherited
163
+ // FABERUN_NOTIFY_SESSION ran this repository's suite, whose fixture
164
+ // controllers woke the operator's live session seven times in minutes.
165
+ for (const name of NOTIFY_ENV_NAMES) delete merged[name];
161
166
  return merged;
162
167
  }
163
168
 
@@ -135,6 +135,74 @@ function proveVerification(id, proof, recorded) {
135
135
  return { id, kind: "verification", ref: proof.ref, pass: entry.passed === true, detail };
136
136
  }
137
137
 
138
+ /**
139
+ * A command proof that declares a node:test filter (`--test-name-pattern`,
140
+ * `--test-skip-pattern`) is judged on more than its exit code, because the
141
+ * runner exits 0 whether its filter selected anything or not. The proof runs
142
+ * with the TAP reporter selected through `NODE_OPTIONS` and is refused when the
143
+ * output carries TAP's zero-plan line. Measured 2026-09-21 on node v26.8.1: a
144
+ * filter that matches emits `1..0` zero times, a filter that matches nothing
145
+ * emits it exactly once, a run with no filter emits it zero times, and an empty
146
+ * suite under a matching filter emits no nested zero plan. The default reporter
147
+ * cannot make the distinction -- both cases print identical counters, because
148
+ * the tick is the file rather than a test. The reporter goes through the
149
+ * environment because appending `--test-reporter=tap` to the command string is
150
+ * a no-op whenever a test file precedes it: node reads its own options
151
+ * left to right, so the flag lands among the script's arguments and the runner
152
+ * never sees it.
153
+ */
154
+ const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
155
+ const TAP_ZERO_PLAN = /^1\.\.0$/mu;
156
+
157
+ /**
158
+ * The environment a filtered proof runs under: the ambient environment with
159
+ * the TAP reporter selected through `NODE_OPTIONS`, minus `NODE_TEST_CONTEXT`.
160
+ * That marker belongs to whichever test runner spawned this process; a nested
161
+ * `node --test` that inherits it stays a runner child and emits no TAP at all
162
+ * (measured 2026-09-21: with the marker the zero-plan line never appears,
163
+ * without it exactly once) -- and a proof is judged as its own top-level run,
164
+ * not as the suite's child.
165
+ *
166
+ * @returns {NodeJS.ProcessEnv}
167
+ */
168
+ function envForFilteredProof() {
169
+ const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = process.env;
170
+ return { ...ambient, NODE_OPTIONS: existing ? `${existing} --test-reporter=tap` : "--test-reporter=tap" };
171
+ }
172
+
173
+ /**
174
+ * The node:test filters a command string declares, in argv order, as flag and
175
+ * value. Presence alone changes behaviour (the appended reporter and the
176
+ * zero-plan look-up); the value is read for the refusal detail alone, which is
177
+ * why a whitespace split is close enough even though the command runs through
178
+ * a shell.
179
+ *
180
+ * @param {string} ref
181
+ * @returns {Array<{flag: string, value: string}>}
182
+ */
183
+ function declaredTestFilters(ref) {
184
+ const tokens = ref.split(/\s+/u).filter(Boolean);
185
+ /** @type {Array<{flag: string, value: string}>} */
186
+ const filters = [];
187
+ for (const [index, token] of tokens.entries()) {
188
+ for (const flag of TEST_FILTER_FLAGS) {
189
+ if (token.startsWith(`${flag}=`)) filters.push({ flag, value: unquote(token.slice(flag.length + 1)) });
190
+ else if (token === flag) filters.push({ flag, value: unquote(tokens[index + 1] ?? "") });
191
+ }
192
+ }
193
+ return filters;
194
+ }
195
+
196
+ /** @param {string} value @returns {string} */
197
+ function unquote(value) {
198
+ return value.replace(/^['"]|['"]$/gu, "");
199
+ }
200
+
201
+ /** @param {Array<{flag: string, value: string}>} filters @returns {string} */
202
+ function filterNames(filters) {
203
+ return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
204
+ }
205
+
138
206
  /**
139
207
  * @param {string} id
140
208
  * @param {DefinitionOfDoneProof} proof
@@ -144,11 +212,18 @@ function proveVerification(id, proof, recorded) {
144
212
  */
145
213
  async function proveCommand(id, proof, cwd, timeoutMs) {
146
214
  const ref = proof.ref;
215
+ const filters = declaredTestFilters(ref);
147
216
  return new Promise((settle) => {
148
217
  // Detached on POSIX so the shell leads its own process group: `shell: true`
149
218
  // means the timeout must kill the group, not the shell, or the command the
150
219
  // shell started keeps running and keeps the result pending forever.
151
- const child = spawn(ref, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
220
+ const child = spawn(ref, {
221
+ cwd,
222
+ shell: true,
223
+ detached: process.platform !== "win32",
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ ...(filters.length ? { env: envForFilteredProof() } : {}),
226
+ });
152
227
  let stdout = "";
153
228
  let stderr = "";
154
229
  let settled = false;
@@ -178,6 +253,18 @@ async function proveCommand(id, proof, cwd, timeoutMs) {
178
253
  finish({ id, kind: "command", ref, pass: false, detail: boundedText(error.message) });
179
254
  });
180
255
  child.on("close", (code, signal) => {
256
+ // Its own detail, not an ordinary command failure: the command succeeded,
257
+ // so what failed is that the declared filter selected nothing to prove.
258
+ if (code === 0 && signal === null && filters.length > 0 && TAP_ZERO_PLAN.test(stdout)) {
259
+ finish({
260
+ id,
261
+ kind: "command",
262
+ ref,
263
+ pass: false,
264
+ detail: boundedText(`${filterNames(filters)} selected no test: exit 0 over a TAP plan of 1..0, so the proof measured nothing`),
265
+ });
266
+ return;
267
+ }
181
268
  const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
182
269
  const pass = code === 0 && signal === null;
183
270
  finish({ id, kind: "command", ref, pass, detail: pass ? detail : boundedText(`${detail}: ${(stderr || stdout).trim()}`) });
@@ -6,6 +6,7 @@ import { dshHarness } from "./dsh/index.mjs";
6
6
  import { zcodeHarness } from "./zcode/index.mjs";
7
7
  import { execJsonlHarness } from "./exec-jsonl/index.mjs";
8
8
  import { replayHarness } from "./replay/index.mjs";
9
+ import { withoutNotifyEnv } from "../notify/index.mjs";
9
10
 
10
11
  /** Current wire-contract version for runner protocol artifacts. */
11
12
  export const PROTOCOL_SCHEMA_VERSION = 3;
@@ -451,7 +452,10 @@ export function probeRuntime(runtime, options = {}) {
451
452
  try {
452
453
  child = spawn(executable, args, {
453
454
  cwd: options.cwd,
454
- env: process.env,
455
+ // A worker or judge never delivers a notification; the controller does.
456
+ // In this repository a worker runs the test suite, whose fixture
457
+ // controllers would otherwise inherit a live transport and deliver.
458
+ env: withoutNotifyEnv(process.env),
455
459
  stdio: ["ignore", "pipe", "pipe"],
456
460
  });
457
461
  } catch (error) {
@@ -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,29 +50,53 @@ 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 { NOTIFY_SESSION_ENV, 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
 
70
75
  export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
71
76
  const MACOS_TRANSPORT = "os-macos";
77
+
78
+ /**
79
+ * Every variable that binds a notification transport. The controller is the
80
+ * only process that delivers: a worker, a judge or a verification command
81
+ * that inherits these would notify on the controller's behalf -- and in this
82
+ * repository, whose workers run its own test suite, every fixture controller
83
+ * the suite spawns would deliver its terminal events for real. Measured
84
+ * 2026-09-21: a run launched with `FABERUN_NOTIFY_SESSION=auto` woke the
85
+ * operator's session seven times in minutes from `test/repo/base-ref.test.mjs`
86
+ * fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
87
+ * crosses; `test/setup.mjs` neutralises the same names inside the suite.
88
+ */
89
+ export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV]);
90
+
91
+ /**
92
+ * @param {NodeJS.ProcessEnv} env
93
+ * @returns {NodeJS.ProcessEnv} a copy with every notify transport unbound
94
+ */
95
+ export function withoutNotifyEnv(env) {
96
+ const copy = { ...env };
97
+ for (const name of NOTIFY_ENV_NAMES) delete copy[name];
98
+ return copy;
99
+ }
72
100
  export const NOTIFY_LOG_FILE = "notify.jsonl";
73
101
  /**
74
102
  * The bounded retry budget the dispatcher used to spend before giving up.
@@ -129,7 +157,8 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
129
157
  /** @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
158
  /** @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
159
  /** @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 */
160
+ /** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
161
+ /** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
133
162
 
134
163
  /**
135
164
  * Render the message for an event. For `node.terminal`, `run.terminal` and
@@ -250,25 +279,31 @@ function truncate(value) {
250
279
  * @returns {string|null}
251
280
  */
252
281
  export function noTransportWarning(env = process.env) {
253
- return env[NOTIFY_BIN_ENV] ? null : NOTIFY_NO_TRANSPORT_WARNING;
282
+ if (env[NOTIFY_BIN_ENV]) return null;
283
+ if (resolveSessionTargets(env).length) return null;
284
+ return NOTIFY_NO_TRANSPORT_WARNING;
254
285
  }
255
286
 
256
287
  /**
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.
288
+ * What `campaign watch --wake` must say about waking. The external transport
289
+ * never wakes anything (`os-macos` and every `FABERUN_NOTIFY_BIN` executable
290
+ * are `canWake: false`: they push to a person); the session transports are
291
+ * the only `canWake: true`, and the notice says which of them the environment
292
+ * resolves to, so the verb never implies a session was woken when none will be.
260
293
  *
261
294
  * @param {string|undefined} [bin]
295
+ * @param {NodeJS.ProcessEnv} [env]
262
296
  * @returns {string}
263
297
  */
264
- export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV]) {
298
+ export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV], env = process.env) {
299
+ const session = sessionWakeNotice(env);
265
300
  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";
301
+ return `no external notify transport is configured; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
267
302
  }
268
303
  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";
304
+ return `os-macos cannot wake a session (canWake: false); --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
270
305
  }
271
- return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; no session is woken`;
306
+ return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
272
307
  }
273
308
 
274
309
  /** @param {string} runsDir @returns {string} */
@@ -345,20 +380,39 @@ export function appendInbox(runsDir, event) {
345
380
  }
346
381
 
347
382
  /**
348
- * Deliver one event through the bound transport. No transport bound resolves
349
- * `{ok: false, noTransport: true}` without spawning anything.
383
+ * Deliver one event through every bound transport at once: the external
384
+ * executable and each resolved harness session, all given the same rendered
385
+ * text. The result is `ok` when any one of them took the message, carries
386
+ * every transport's own outcome for the receipt, and is
387
+ * `{ok: false, noTransport: true}` when nothing at all is bound -- without
388
+ * spawning or connecting anything.
350
389
  *
351
390
  * @param {{type: string, summary: string, campaignId?: string|null, [key: string]: unknown}} event
352
- * @param {{bin?: string, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
391
+ * @param {{bin?: string, env?: NodeJS.ProcessEnv, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
353
392
  * @returns {Promise<DeliveryResult>}
354
393
  */
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));
394
+ async function deliverNotification(event, options = {}) {
395
+ const env = options.env ?? process.env;
396
+ const bin = options.bin ?? env[NOTIFY_BIN_ENV];
397
+ const targets = resolveSessionTargets(env);
398
+ /** @type {Promise<TransportOutcome>[]} */
399
+ const attempts = [];
400
+ if (bin) {
401
+ const external = bin === MACOS_TRANSPORT
402
+ ? createMacosNotifier({ spawn: options.spawn }).deliver(/** @type {any} */ (event))
403
+ : spawnDeliver(bin, event, options);
404
+ attempts.push(external.then((result) => ({ id: bin === MACOS_TRANSPORT ? MACOS_TRANSPORT : "bin", ...result })));
360
405
  }
361
- return spawnDeliver(bin, event, options);
406
+ /** @type {Promise<TransportOutcome[]>} */
407
+ const sessions = targets.length ? deliverToSessions(event, targets, { timeoutMs: options.timeoutMs, env }) : Promise.resolve([]);
408
+ if (!attempts.length && !targets.length) return { ok: false, noTransport: true, transports: [] };
409
+ const transports = [...(await Promise.all(attempts)), ...(await sessions)];
410
+ const failures = transports.filter((outcome) => !outcome.ok).map((outcome) => `${outcome.id}: ${outcome.error ?? "failed"}`);
411
+ return {
412
+ ok: transports.some((outcome) => outcome.ok),
413
+ ...(failures.length ? { error: failures.join("; ") } : {}),
414
+ transports,
415
+ };
362
416
  }
363
417
 
364
418
  /**
@@ -472,6 +526,9 @@ export class NotifyQueue {
472
526
  summary,
473
527
  attempt: 1,
474
528
  status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
529
+ // One entry per bound transport, so a receipt that says `delivered`
530
+ // also says whether the phone, the session, or both took the message.
531
+ transports: result.transports ?? [],
475
532
  at: new Date(this.now()).toISOString(),
476
533
  };
477
534
  if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;