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