faberun 0.17.2 → 0.19.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.
Files changed (44) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/faberun/references/operations.md +14 -7
  4. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  5. package/src/campaign/chain.mjs +5 -4
  6. package/src/campaign/metrics.mjs +3 -1
  7. package/src/cli/launch.mjs +10 -1
  8. package/src/cli.mjs +10 -4
  9. package/src/contract/index.mjs +16 -3
  10. package/src/contract/task-packet.mjs +13 -1
  11. package/src/engine/bulk-read.mjs +7 -1
  12. package/src/engine/gate.mjs +17 -3
  13. package/src/engine/judge-gate.mjs +2 -2
  14. package/src/engine/live-preflight.mjs +43 -10
  15. package/src/engine/live-silence.mjs +49 -0
  16. package/src/engine/process-identity.mjs +12 -1
  17. package/src/engine/process.mjs +2 -1
  18. package/src/engine/run-command.mjs +8 -5
  19. package/src/engine/run-identity.mjs +213 -14
  20. package/src/engine/runtime-discovery.mjs +26 -5
  21. package/src/engine/scheduler.mjs +1 -1
  22. package/src/engine/supervise.mjs +2 -1
  23. package/src/harnesses/catalogue.mjs +8 -1
  24. package/src/harnesses/dsh/runner.mjs +6 -1
  25. package/src/harnesses/index.mjs +35 -7
  26. package/src/host/platform.mjs +204 -1
  27. package/src/host/preflight.mjs +142 -7
  28. package/src/notify/index.mjs +77 -11
  29. package/src/notify/session.mjs +64 -24
  30. package/src/plan/pipeline.mjs +5 -1
  31. package/src/plan/preflight.mjs +77 -0
  32. package/src/repo/declared-paths.mjs +87 -6
  33. package/src/repo/signal.mjs +56 -21
  34. package/src/repo/workspace.mjs +3 -2
  35. package/src/repo/worktree.mjs +2 -1
  36. package/src/report/locale.mjs +20 -0
  37. package/src/report/message.mjs +2 -2
  38. package/src/report/next.mjs +15 -1
  39. package/src/run/availability.mjs +138 -0
  40. package/src/run/disk-gc.mjs +16 -2
  41. package/src/run/lock.mjs +8 -0
  42. package/src/run/paths.mjs +13 -0
  43. package/src/seat/allowance.mjs +4 -1
  44. package/src/seat/tmux.mjs +9 -1
@@ -21,7 +21,9 @@
21
21
  * `node.exe` and there is no extensionless file beside it, so a lookup that
22
22
  * only joins the bare name reports the running interpreter as missing.
23
23
  */
24
- import { existsSync, renameSync, rmSync, symlinkSync } from "node:fs";
24
+ import { Buffer } from "node:buffer";
25
+ import { spawnSync } from "node:child_process";
26
+ import { closeSync, existsSync, openSync, readSync, renameSync, rmSync, symlinkSync } from "node:fs";
25
27
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
26
28
 
27
29
  /**
@@ -109,3 +111,204 @@ function executableCandidates(name) {
109
111
  const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
110
112
  return [...extensions.map((extension) => `${name}${extension}`), name];
111
113
  }
114
+
115
+ /** A command script Windows runs through the command interpreter, not directly. */
116
+ const COMMAND_SCRIPT = /\.(?:cmd|bat)$/iu;
117
+
118
+ /**
119
+ * How this platform has to be asked to run `executable` with `args`.
120
+ *
121
+ * POSIX hands both back untouched. Windows has two problems with the binaries
122
+ * a harness actually installs as, and both are this function's business so no
123
+ * caller has to know about either.
124
+ *
125
+ * A command name resolves through `PATHEXT`, and `spawn` does not do it: with
126
+ * only `claude.cmd` on PATH, `spawn("claude")` is ENOENT. So a bare name is
127
+ * resolved here first.
128
+ *
129
+ * And a `.cmd` or `.bat` cannot be spawned directly at all — node refuses it
130
+ * with EINVAL since the argument-injection fix — so it runs through
131
+ * `%ComSpec% /d /s /c`. Every argument is quoted for `CommandLineToArgvW` and
132
+ * then caret-escaped *twice*: the interpreter consumes one layer parsing the
133
+ * command line, and the `%*` that every npm-written shim forwards its
134
+ * arguments with consumes the second. Measured 2026-09-20 on Windows 11 —
135
+ * with one layer, an argument containing `&` is truncated at it; with two,
136
+ * spaces, quotes, `&`, `|`, `%`, `^`, `()` and `!` all arrive intact.
137
+ *
138
+ * A relative executable — `./my-worker.mjs`, the shape a contract names a
139
+ * wrapped harness by — is relative to the directory the child will run in, and
140
+ * the caller is the only one who knows it. Without `cwd` the shebang is looked
141
+ * for beside *this* process instead, found nowhere, and the spawn fails as
142
+ * EFTYPE with nothing said about why.
143
+ *
144
+ * @param {string} executable
145
+ * @param {string[]} args
146
+ * @param {{cwd?: string}} [options] the directory the child will run in
147
+ * @returns {{command: string, args: string[], options: {windowsVerbatimArguments?: boolean}}}
148
+ */
149
+ export function spawnInvocation(executable, args, options = {}) {
150
+ if (process.platform !== "win32") return { command: executable, args, options: {} };
151
+ const named = executable.includes("/") || executable.includes("\\");
152
+ const resolved = named
153
+ ? (options.cwd === undefined ? executable : resolve(options.cwd, executable))
154
+ : (findExecutable(executable) ?? executable);
155
+ if (COMMAND_SCRIPT.test(resolved)) {
156
+ const line = [quoteArgument(resolved), ...args.map((argument) => escapeThroughShim(quoteArgument(argument)))].join(" ");
157
+ return {
158
+ command: process.env.ComSpec ?? "cmd.exe",
159
+ args: ["/d", "/s", "/c", `"${line}"`],
160
+ options: { windowsVerbatimArguments: true },
161
+ };
162
+ }
163
+ const shebang = interpreterOf(resolved);
164
+ if (shebang) return { command: shebang[0], args: [...shebang.slice(1), resolved, ...args], options: {} };
165
+ return { command: resolved, args, options: {} };
166
+ }
167
+
168
+ /**
169
+ * The interpreter a `#!` line names, or null when the file has none.
170
+ *
171
+ * A shebang is a POSIX kernel feature, so Windows hands the same file back as
172
+ * EFTYPE. Reading the line and running the interpreter is what cross-spawn —
173
+ * and therefore npm — does on Windows, and it is the only way an executable
174
+ * handed over as a POSIX script (`FABERUN_CODEX_BIN` pointed at the
175
+ * extensionless file npm installs beside its `.cmd`, a harness wrapper written
176
+ * by hand) runs there at all.
177
+ *
178
+ * @param {string} path
179
+ * @returns {string[]|null} the interpreter and its own arguments
180
+ */
181
+ function interpreterOf(path) {
182
+ /** @type {number|undefined} */
183
+ let handle;
184
+ try {
185
+ handle = openSync(path, "r");
186
+ const head = Buffer.alloc(256);
187
+ const read = readSync(handle, head, 0, 256, 0);
188
+ const first = head.subarray(0, read).toString("utf8").split(/\r?\n/u, 1)[0];
189
+ if (!first.startsWith("#!")) return null;
190
+ const line = first.slice(2).trim();
191
+ if (!line) return null;
192
+ // A whole line that names a real file is the interpreter, spaces and all:
193
+ // every fixture here is written with `#!${process.execPath}`, and on
194
+ // Windows that is `C:\Program Files\nodejs\node.exe`. Splitting it on
195
+ // whitespace the POSIX way would ask for `C:\Program`.
196
+ if (existsSync(line)) return [line];
197
+ // An interpreter named by a POSIX path that this host does not have:
198
+ // `#!/bin/sh` is not a file on Windows, but `sh` is a command there
199
+ // wherever Git for Windows is installed, which is wherever this tool runs.
200
+ const named = line.includes(" ") ? null : findExecutable(line.slice(line.lastIndexOf("/") + 1));
201
+ if (named) return [named];
202
+ // `#!/usr/bin/env node` names the interpreter in its own argument.
203
+ const parts = line.split(/\s+/u).filter(Boolean);
204
+ if (!parts.length) return null;
205
+ if (/(?:^|[\\/])env(?:\.exe)?$/iu.test(parts[0])) return parts.slice(1).length ? parts.slice(1) : null;
206
+ return parts;
207
+ } catch {
208
+ // Unreadable, a directory, or gone: there is no shebang to honour, and the
209
+ // spawn below reports the real reason.
210
+ return null;
211
+ } finally {
212
+ if (handle !== undefined) closeSync(handle);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * One argument, quoted the way `CommandLineToArgvW` reads it back: a backslash
218
+ * run before a quote doubles, and a trailing run doubles so the closing quote
219
+ * survives.
220
+ *
221
+ * @param {string} value
222
+ * @returns {string}
223
+ */
224
+ function quoteArgument(value) {
225
+ return `"${String(value).replace(/(\*)"/gu, '$1$1\\"').replace(/(\+)$/u, "$1$1")}"`;
226
+ }
227
+
228
+ /**
229
+ * @param {string} value
230
+ * @returns {string}
231
+ */
232
+ function escapeThroughShim(value) {
233
+ return value.replace(/[()%!^"<>&|]/gu, "^$&").replace(/[()%!^"<>&|]/gu, "^$&");
234
+ }
235
+
236
+ /**
237
+ * End a process and everything it started, the way this platform can.
238
+ *
239
+ * On POSIX the caller passes a negative pid and the kernel signals the whole
240
+ * process group, which is what every signalling path here already does.
241
+ * Windows has no process group to signal: `process.kill(pid)` ends that one
242
+ * process, and the provider a gate spawned — or the `node` a `.cmd` shim
243
+ * started — keeps running. Measured 2026-09-20: a full suite run on Windows 11
244
+ * stranded 105 `node -e "setInterval(…)"` fixtures this way, and then hung
245
+ * waiting for one of them to die.
246
+ *
247
+ * `taskkill /T` walks the tree from the pid. `/F` is the only ending Windows
248
+ * offers a console process, so there is no graceful step to escalate from —
249
+ * which is why every caller already skips its `SIGKILL` escalation there.
250
+ * A non-zero exit means no such process, read the way a POSIX `ESRCH` is: the
251
+ * target is gone, which is not a failure to report.
252
+ *
253
+ * @param {number} target a pid, or on POSIX a negative process-group id
254
+ * @param {string|number} signal
255
+ * @returns {unknown}
256
+ */
257
+ export function killTarget(target, signal) {
258
+ if (process.platform !== "win32") return process.kill(target, signal);
259
+ const result = spawnSync(join(process.env.SystemRoot ?? "C:\Windows", "System32", "taskkill.exe"), ["/PID", String(Math.abs(target)), "/T", "/F"], { stdio: "ignore" });
260
+ if (result.error) throw result.error;
261
+ if (result.status !== 0) throw Object.assign(new Error(`taskkill found no process ${Math.abs(target)}`), { code: "ESRCH" });
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * The `git` argument list every invocation here carries.
267
+ *
268
+ * **`core.fsmonitor=false`, on every platform.** A repository with the file
269
+ * system monitor enabled starts `git fsmonitor--daemon --detach` on first use,
270
+ * and the daemon outlives the directory it watched. This tool creates a
271
+ * worktree per attempt and its suite creates a throwaway repository per
272
+ * fixture, so the daemons accumulate: measured 2026-09-20 on Windows 11, one
273
+ * afternoon of suite runs left **4810** of them holding 39 GB, until the
274
+ * machine could not start another test process. Nothing here benefits from a
275
+ * daemon watching a tree that is about to be deleted.
276
+ *
277
+ * **`core.longpaths=true`, on Windows.** It caps a path at 260 characters
278
+ * unless the program opts out, and git's
279
+ * own `core.longpaths` is off by default even where the OS itself allows long
280
+ * paths. The run layout is deep on purpose — `projects/<id>/runs/worktrees/
281
+ * <run>/<node>.<attempt>` before the repository's own tree begins — so a file
282
+ * a worker writes a few directories down reaches the cap easily.
283
+ *
284
+ * What makes this worth a flag on every call is *how* git fails there.
285
+ * Measured 2026-09-20 on Windows 11, a repository holding one file at a
286
+ * 268-character path: `git add --all` exits 0 and tracks nothing. With
287
+ * `core.longpaths=true` it exits 0 and tracks the file. A tool whose gate is
288
+ * the diff of what a worker changed cannot afford a git that silently cannot
289
+ * see part of the tree.
290
+ *
291
+ * It does not lift every limit: `git worktree add` dies with `'$GIT_DIR' too
292
+ * big` once the repository's own `.git/worktrees/<name>` path passes about 160
293
+ * characters, and that guard is a fixed buffer no configuration reaches.
294
+ *
295
+ * **`core.autocrlf=false`, on Windows.** The default there is `true`, and it
296
+ * rewrites line endings on the way into the index and out of a checkout. This
297
+ * tool's gate is a byte comparison of what a worker changed, and a rewrite it
298
+ * did not make is indistinguishable from one it did: measured 2026-09-22 on a
299
+ * GitHub windows-latest runner, a declared read carried into an attempt
300
+ * worktree came back changed after passing through git, the scope gate called
301
+ * it an `unexpected_write`, and the adopted node failed for a file nobody had
302
+ * touched. The attempt worktree is this tool's own directory and its content
303
+ * is what the run commits; the operator's checkout keeps whatever they
304
+ * configured.
305
+ *
306
+ * @param {string[]} args
307
+ * @returns {string[]}
308
+ */
309
+ export function gitArguments(args) {
310
+ const platformArgs = process.platform === "win32"
311
+ ? ["-c", "core.longpaths=true", "-c", "core.autocrlf=false"]
312
+ : [];
313
+ return ["-c", "core.fsmonitor=false", ...platformArgs, ...args];
314
+ }
@@ -8,6 +8,17 @@
8
8
  * report as run evidence and stops, so the operator fixes the host and
9
9
  * resumes instead of starting over and paying for the finished nodes twice.
10
10
  *
11
+ * A version is not a verdict: a binary that answered `--version` can still
12
+ * hold a dead credential or a spent quota. With a contract, `doctor` asks
13
+ * each routed runtime the same live question the dispatch gate asks and
14
+ * reports that verdict beside the version checks — the `models` surfaces
15
+ * name this report the authoritative word on availability — and without a
16
+ * contract it says plainly that it asked nothing. The verdict is the
17
+ * report, not the gate: a runtime with no answer is a finding on its own
18
+ * line, while the exit code keeps answering the host-fact question it
19
+ * always answered, because blocking on silence is the dispatch gate's job
20
+ * and it blocks on exactly those causes.
21
+ *
11
22
  * A check may be advisory, meaning it reports a fact without blocking: a
12
23
  * merely dirty worktree is normal in this repository (the run captures a
13
24
  * dirtyTreeFingerprint for it), while unmerged paths or an interrupted git
@@ -18,6 +29,7 @@ import { spawnSync } from "node:child_process";
18
29
  import { existsSync, readFileSync, statfsSync } from "node:fs";
19
30
  import { join, resolve } from "node:path";
20
31
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
32
+ import { liveSilenceCause } from "../engine/live-silence.mjs";
21
33
  import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
22
34
  import { pricingSeedAge } from "../engine/pricing-seed.mjs";
23
35
  import { validateContract } from "../contract/index.mjs";
@@ -26,15 +38,18 @@ import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runti
26
38
  import { errorMessage } from "../util.mjs";
27
39
  import { boundedGitSync } from "../repo/worktree.mjs";
28
40
  import { routeRuntime } from "../contract/runtime.mjs";
29
- import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
41
+ import { NOTIFY_BIN_ENV, deliverableEventTypes, noTransportWarning, notifySettingProblems } from "../notify/index.mjs";
30
42
  import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
31
43
  import { findExecutable } from "./platform.mjs";
32
44
  import { colorLevel, statusToken } from "../cli/brand.mjs";
33
45
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
46
+ import { availabilityKey, readAvailability, recordAvailability } from "../run/availability.mjs";
34
47
 
35
48
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
36
49
  /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
37
50
  /** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
51
+ /** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
52
+ /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
38
53
  /** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
39
54
  /** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
40
55
  /** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
@@ -209,10 +224,13 @@ export function environmentPreflight(options) {
209
224
  * @returns {EnvCheck}
210
225
  */
211
226
  export function notifyTransportCheck(env = process.env) {
227
+ const problems = notifySettingProblems(env);
228
+ if (problems.length) return fail("notify transport", problems.join("; "), true);
212
229
  const warning = noTransportWarning(env);
213
230
  if (warning) return fail("notify transport", warning, true);
214
231
  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)}`);
232
+ const events = [...deliverableEventTypes(env)].join(",");
233
+ return pass("notify transport", `${external} · ${sessionWakeNotice(env)} · events: ${events}`);
216
234
  }
217
235
 
218
236
  /** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
@@ -381,6 +399,106 @@ export function reachableRuntimes(contract) {
381
399
  return runtimes;
382
400
  }
383
401
 
402
+
403
+ /**
404
+ * Read the live verdict off one `preflightContract` probe, in the detail it
405
+ * embeds: `… · live <status> · <code>: …` for an ask that failed, and the
406
+ * repository wording when no runtime could be asked at all. Any verdict a
407
+ * provider produced — a quota refusal, an auth failure, unparsable output —
408
+ * is an answer; a silence is a verdict of nothing and is never recorded, so
409
+ * the operator who fixes the host is never told the fix "already answered".
410
+ *
411
+ * @param {ProbeResult} probe
412
+ * @returns {{answered: boolean, cause: string|null, recorded: boolean}}
413
+ */
414
+ function liveVerdict(probe) {
415
+ if (probe.liveStatus === "done") return { answered: true, cause: null, recorded: true };
416
+ // `liveSilenceCause` is the dispatch gate's own classification, imported
417
+ // rather than restated. A second copy here had `command_invalid` in it,
418
+ // which the gate deliberately does not: a command that could not be
419
+ // constructed never reached a provider, so there is no availability verdict
420
+ // to report either way. Two copies of this rule is how doctor and the gate
421
+ // would come to disagree about whether an answer was an answer.
422
+ const silence = liveSilenceCause(probe);
423
+ if (silence !== null) return { answered: false, cause: silence, recorded: false };
424
+ const match = / · live \S+ · ([a-z_]+):/u.exec(probe.detail ?? "");
425
+ const cause = match?.[1] ?? null;
426
+ if (cause === null) return { answered: false, cause, recorded: false };
427
+ return { answered: true, cause, recorded: true };
428
+ }
429
+
430
+ /**
431
+ * The live half of `doctor`: the verdict, not the version. The two catalogue
432
+ * surfaces (`models`, `models --probe`) name this report the authoritative
433
+ * word on availability, so with a contract doctor asks what the dispatch
434
+ * gate asks — one trivial prompt per routed runtime through
435
+ * `preflightContract` — and reports beside the version checks what
436
+ * answered, naming the cause when nothing did.
437
+ *
438
+ * A verdict this machine recorded inside the preflight window is reused
439
+ * rather than re-bought, and every ask that reached a provider is recorded
440
+ * in turn: the store (`run/availability.mjs`) is the only cache; none is
441
+ * built here.
442
+ *
443
+ * Every check returned here is advisory. `ok` carries the honest verdict —
444
+ * false when nothing answered — but the lines never gate `doctor`'s exit
445
+ * code: the report is where the operator reads what answered, and blocking
446
+ * on silence is the dispatch gate's decision, made on the same causes. A
447
+ * doctor that failed on a runtime it could not ask inside its own sandbox
448
+ * (a relative-executable wrapper) would report the runtime broken when the
449
+ * actual launch may resolve it fine.
450
+ *
451
+ * @param {string} contractPath the authored contract, re-read and re-validated by the ask
452
+ * @param {ReachableRuntimes} runtimes
453
+ * @returns {Promise<EnvCheck[]>}
454
+ */
455
+ async function liveAvailabilityChecks(contractPath, runtimes) {
456
+ /** @type {Map<string, RuntimeAvailability>} */
457
+ const fresh = new Map();
458
+ for (const [id, { runtime }] of runtimes) {
459
+ const verdict = readAvailability(availabilityKey({
460
+ harness: runtime.harness,
461
+ model: runtime.model,
462
+ executable: getHarness(runtime.harness).executable(runtime),
463
+ }));
464
+ if (verdict) fresh.set(id, verdict);
465
+ }
466
+ if (runtimes.size > 0 && fresh.size === runtimes.size) {
467
+ return [...runtimes.keys()].map((id) => {
468
+ const verdict = /** @type {RuntimeAvailability} */ (fresh.get(id));
469
+ return {
470
+ name: `availability ${id}`,
471
+ ok: true,
472
+ advisory: true,
473
+ detail: `answered · verdict reused · observed ${verdict.observedAt ?? "unknown instant"}`,
474
+ };
475
+ });
476
+ }
477
+ // The ask is imported where it runs: live-preflight imports this module's
478
+ // reachableRuntimes, so a static edge here would be the runtime import
479
+ // cycle the source gate bans. At call time both modules are fully
480
+ // evaluated; this is a plain cache hit, not a cycle.
481
+ const { preflightContract } = await import("../engine/live-preflight.mjs");
482
+ // measured 2026-09-22 (dispatch gate): four routed runtimes asked in
483
+ // parallel took about 18s, so 60s is the budget; FABERUN_PREFLIGHT_TIMEOUT_SEC
484
+ // is the same operator override the gate honours.
485
+ const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
486
+ const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
487
+ const probes = await preflightContract(contractPath, { liveTimeoutSec: timeoutSec });
488
+ recordAvailability(probes.filter((probe) => liveVerdict(probe).recorded).map((probe) => availabilityKey(probe)));
489
+ return probes.map((probe) => {
490
+ const verdict = liveVerdict(probe);
491
+ return {
492
+ name: `availability ${probe.id ?? probe.harness}`,
493
+ ok: verdict.answered,
494
+ advisory: true,
495
+ detail: verdict.answered
496
+ ? `answered · ${probe.detail ?? `the provider answered (${verdict.cause})`}`
497
+ : `no answer · ${probe.detail ?? `cause ${verdict.cause ?? "unknown"}`}`,
498
+ };
499
+ });
500
+ }
501
+
384
502
  const HARNESS_BIN_OVERRIDES = Object.freeze({
385
503
  codex: "FABERUN_CODEX_BIN",
386
504
  claude: "FABERUN_CLAUDE_BIN",
@@ -390,9 +508,17 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
390
508
  });
391
509
 
392
510
  /**
393
- * Mutation-free environment doctor: repository prerequisites, ignored .runs,
394
- * required binaries, the dispatch environment gate, and (when a contract is
395
- * given) schema and harness versions.
511
+ * Environment doctor: repository prerequisites, ignored .runs, required
512
+ * binaries, the dispatch environment gate, and (when a contract is given)
513
+ * the harness versions beside the live availability verdict per routed
514
+ * runtime — the report `models` defers to. Without a contract it says
515
+ * plainly that it asked nothing.
516
+ *
517
+ * The exit code answers the host-fact question it always answered: the
518
+ * live availability lines carry their verdict (false when nothing
519
+ * answered) but are advisory, because blocking on silence is the dispatch
520
+ * gate's job and a runtime doctor could not ask inside its own sandbox is
521
+ * not thereby a runtime the launch cannot run.
396
522
  *
397
523
  * @param {string|undefined} contractPath
398
524
  * @param {{cwd?: string, json?: boolean, discover?: boolean}} values
@@ -400,7 +526,7 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
400
526
  */
401
527
  export async function doctorCommand(contractPath, values) {
402
528
  const repoDir = resolve(values.cwd ?? ".");
403
- /** @type {{name: string, ok: boolean, detail: string}[]} */
529
+ /** @type {{name: string, ok: boolean, advisory?: boolean, detail: string}[]} */
404
530
  const checks = [];
405
531
  const gitRepo = isGitWorkTree(repoDir);
406
532
  checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
@@ -454,6 +580,10 @@ export async function doctorCommand(contractPath, values) {
454
580
  harnessVersions[id] = probe.version;
455
581
  checks.push({ name: `harness ${probe.id ?? runtime.harness}`, ok: probe.ok, detail: probe.detail ?? (probe.ok ? "ok" : "probe failed") });
456
582
  }
583
+ // The verdict beside the version: a binary that answered --version is
584
+ // not thereby a provider that answered, and the report names which of
585
+ // the two failed because the remedies differ.
586
+ checks.push(...await liveAvailabilityChecks(absolute, runtimes));
457
587
  } catch (error) {
458
588
  checks.push({ name: "contract", ok: false, detail: errorMessage(error) });
459
589
  }
@@ -494,7 +624,12 @@ export async function doctorCommand(contractPath, values) {
494
624
  for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
495
625
  checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
496
626
  }
497
- const ok = checks.every((check) => check.ok);
627
+ // The live availability lines never gate the verdict: a no-answer is the
628
+ // finding it is on its own line (ok false, cause named), and what still
629
+ // fails doctor is every host fact and static probe — which is why the
630
+ // nonexistent-binary case below exits non-zero while a merely silent
631
+ // provider does not.
632
+ const ok = checks.every((check) => check.ok || check.advisory === true);
498
633
  const transportWarning = noTransportWarning(process.env);
499
634
  if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
500
635
  if (values.json === true) {
@@ -51,6 +51,7 @@ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync
51
51
  import { join } from "node:path";
52
52
  import { createMacosNotifier } from "./os-macos.mjs";
53
53
  import { NOTIFY_SESSION_ENV, deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
54
+ import { spawnInvocation } from "../host/platform.mjs";
54
55
  import { errorMessage } from "../util.mjs";
55
56
 
56
57
  /**
@@ -86,7 +87,59 @@ const MACOS_TRANSPORT = "os-macos";
86
87
  * fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
87
88
  * crosses; `test/setup.mjs` neutralises the same names inside the suite.
88
89
  */
89
- export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV]);
90
+ export const NOTIFY_EVENTS_ENV = "FABERUN_NOTIFY_EVENTS";
91
+ export const NOTIFY_LANG_ENV = "FABERUN_NOTIFY_LANG";
92
+ export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV, NOTIFY_EVENTS_ENV, NOTIFY_LANG_ENV]);
93
+
94
+ /** Every event type the dispatcher can be asked to deliver. */
95
+ export const NOTIFY_EVENT_TYPES = Object.freeze(["node.terminal", "run.terminal", "attention", "advisory"]);
96
+
97
+ /**
98
+ * What leaves the controller when `FABERUN_NOTIFY_EVENTS` is unset: a phase
99
+ * settling, a node that waits on a person, and an advisory threshold the
100
+ * operator declared. A node settling stays in `notify.jsonl` as a `filtered`
101
+ * receipt. The operator's own words, 2026-09-22, after a run of two nodes
102
+ * produced four wake-ups: "só fechamento de fase e atenção acordam, nó
103
+ * individual fica no log" -- and before that, after the phone flood, "só
104
+ * milestones e fechamentos de fase". Measured on the campaign that prompted
105
+ * it: five phases of two or three nodes would be about 20 messages with every
106
+ * event, about 7 with these.
107
+ */
108
+ export const DEFAULT_NOTIFY_EVENTS = Object.freeze(["run.terminal", "attention", "advisory"]);
109
+
110
+ /** The two languages the message renders its wording in; `FABERUN_NOTIFY_LANG` may name either. */
111
+ export const NOTIFY_LANGUAGES = Object.freeze(["en", "pt"]);
112
+
113
+ /**
114
+ * The event types the environment lets out, as a set. Unknown items are left
115
+ * out here and reported by `notifySettingProblems`; an empty value is the
116
+ * default, never "nothing".
117
+ *
118
+ * @param {NodeJS.ProcessEnv} [env]
119
+ * @returns {Set<string>}
120
+ */
121
+ export function deliverableEventTypes(env = process.env) {
122
+ const items = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
123
+ return new Set(items.length ? items.filter((item) => NOTIFY_EVENT_TYPES.includes(item)) : DEFAULT_NOTIFY_EVENTS);
124
+ }
125
+
126
+ /**
127
+ * Every notify setting the environment gets wrong, one sentence each, for
128
+ * `doctor` and `preflight`. Empty when everything parses.
129
+ *
130
+ * @param {NodeJS.ProcessEnv} [env]
131
+ * @returns {string[]}
132
+ */
133
+ export function notifySettingProblems(env = process.env) {
134
+ const problems = [];
135
+ const events = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
136
+ for (const item of events) {
137
+ if (!NOTIFY_EVENT_TYPES.includes(item)) problems.push(`${NOTIFY_EVENTS_ENV} item "${item}" is not one of ${NOTIFY_EVENT_TYPES.join(", ")}`);
138
+ }
139
+ const lang = (env[NOTIFY_LANG_ENV] ?? "").trim();
140
+ if (lang && !NOTIFY_LANGUAGES.includes(lang)) problems.push(`${NOTIFY_LANG_ENV}=${lang} is not one of ${NOTIFY_LANGUAGES.join(", ")}`);
141
+ return problems;
142
+ }
90
143
 
91
144
  /**
92
145
  * @param {NodeJS.ProcessEnv} env
@@ -156,7 +209,7 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
156
209
  /** @typedef {Record<string, unknown>} JsonObject */
157
210
  /** @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 */
158
211
  /** @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 */
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 */
212
+ /** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory", 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 */
160
213
  /** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
161
214
  /** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
162
215
 
@@ -432,7 +485,12 @@ function spawnDeliver(bin, event, { spawn = defaultSpawn, timeoutMs = notificati
432
485
  return new Promise((resolveDelivery) => {
433
486
  let child;
434
487
  try {
435
- child = spawn(bin, [], { stdio: ["pipe", "ignore", "pipe"], env: process.env });
488
+ // The transport is whatever the operator bound, and on Windows that is
489
+ // rarely an .exe: a `.cmd` shim npm wrote, or a script whose shebang
490
+ // names its interpreter. Either spawns as EFTYPE unhandled, which is a
491
+ // notification that silently never arrives.
492
+ const invocation = spawnInvocation(bin, []);
493
+ child = spawn(invocation.command, invocation.args, { stdio: ["pipe", "ignore", "pipe"], env: process.env, ...invocation.options });
436
494
  } catch (error) {
437
495
  resolveDelivery({ ok: false, error: errorMessage(error) });
438
496
  return;
@@ -503,14 +561,22 @@ export class NotifyQueue {
503
561
  // one), so every delivery carries a stable id derived from the dedupe key.
504
562
  const eventId = enriched.eventId
505
563
  ?? createHash("sha256").update(enriched.dedupeKey ?? JSON.stringify(enriched)).digest("hex");
564
+ // An event type the environment keeps out of every transport is still a
565
+ // receipt -- `filtered`, with the rendered summary -- so the log says what
566
+ // happened to it, and the resume's dedupe sees it as already handled.
567
+ const filtered = !deliverableEventTypes().has(enriched.type);
506
568
  /** @type {DeliveryResult} */
507
569
  let result;
508
- try {
509
- result = await this.deliver({ ...enriched, summary, eventId });
510
- } catch (error) {
511
- // A transport that rejects is a failed delivery, not a controller fault:
512
- // the receipt is still appended and the failure is dropped like any other.
513
- result = { ok: false, error: errorMessage(error) };
570
+ if (filtered) {
571
+ result = { ok: false, transports: [] };
572
+ } else {
573
+ try {
574
+ result = await this.deliver({ ...enriched, summary, eventId });
575
+ } catch (error) {
576
+ // A transport that rejects is a failed delivery, not a controller fault:
577
+ // the receipt is still appended and the failure is dropped like any other.
578
+ result = { ok: false, error: errorMessage(error) };
579
+ }
514
580
  }
515
581
  /** @type {JsonObject} */
516
582
  const receipt = {
@@ -525,13 +591,13 @@ export class NotifyQueue {
525
591
  dedupeKey: enriched.dedupeKey ?? null,
526
592
  summary,
527
593
  attempt: 1,
528
- status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
594
+ status: filtered ? "filtered" : result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
529
595
  // One entry per bound transport, so a receipt that says `delivered`
530
596
  // also says whether the phone, the session, or both took the message.
531
597
  transports: result.transports ?? [],
532
598
  at: new Date(this.now()).toISOString(),
533
599
  };
534
- if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
600
+ if (!filtered && !result.ok && !result.noTransport) receipt.error = result.error ?? null;
535
601
  appendFileSync(join(this.runDir, NOTIFY_LOG_FILE), `${JSON.stringify(receipt)}\n`);
536
602
  }
537
603
  }