catherd-cli 1.0.0 → 1.1.1

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 (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -60,14 +60,15 @@ export function step(items: readonly { selectable: boolean }[], from: number, by
60
60
 
61
61
  /**
62
62
  * A view's selected row: `selected` is what this render draws, `current()` what a key handler must read
63
- * (a key in the same tick may have moved it already).
63
+ * (a key in the same tick may have moved it already). `initial`: the row it starts on (a list snaps a key it
64
+ * does not hold to its first row).
64
65
  */
65
- export function useSelected(): {
66
+ export function useSelected(initial: string | null = null): {
66
67
  selected: string | null;
67
68
  select(key: string): void;
68
69
  current(): string | null;
69
70
  } {
70
- const [selected, setSelected] = useState<string | null>(null);
71
+ const [selected, setSelected] = useState<string | null>(initial);
71
72
  const ref = useRef<string | null>(null);
72
73
  ref.current = selected;
73
74
  return {
@@ -0,0 +1,98 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { claudeHome } from "./paths.ts";
4
+ import { isAlive } from "./proc.ts";
5
+
6
+ /**
7
+ * The Claude Code session catherd's MCP server runs in (spec §3.3; docs/research/2026-09-28-cross-session-messaging.md):
8
+ * what its environment says, and what the session's registry file (`<config>/sessions/<pid>.json`) says now.
9
+ */
10
+
11
+ /** The four variables a Claude Code session hands its MCP servers; each may be absent. */
12
+ export const SESSION_ENV_KEYS = {
13
+ sessionId: "CLAUDE_CODE_SESSION_ID",
14
+ hostSessionId: "CLAUDE_CODE_HOST_SESSION_ID",
15
+ socketPath: "CLAUDE_CODE_MESSAGING_SOCKET",
16
+ token: "CLAUDE_CODE_MESSAGING_TOKEN",
17
+ } as const;
18
+
19
+ export interface SessionEnv {
20
+ /** the id at spawn: it goes stale after /clear, so the registry's is preferred */
21
+ sessionId: string | null;
22
+ /** set only when a host (Desktop) launched the session */
23
+ hostSessionId: string | null;
24
+ socketPath: string | null;
25
+ token: string | null;
26
+ }
27
+
28
+ /** The session's environment, or null outside Claude Code (neither a session id nor a socket). */
29
+ export function readSessionEnv(env: Record<string, string | undefined> = process.env): SessionEnv | null {
30
+ const get = (k: string) => env[k] || null;
31
+ const s: SessionEnv = {
32
+ sessionId: get(SESSION_ENV_KEYS.sessionId),
33
+ hostSessionId: get(SESSION_ENV_KEYS.hostSessionId),
34
+ socketPath: get(SESSION_ENV_KEYS.socketPath),
35
+ token: get(SESSION_ENV_KEYS.token),
36
+ };
37
+ return s.sessionId || s.socketPath ? s : null;
38
+ }
39
+
40
+ /** One live session's registry entry, as far as catherd reads it. */
41
+ export interface SessionFile {
42
+ pid: number;
43
+ sessionId: string;
44
+ name: string | null;
45
+ hostSessionId: string | null;
46
+ messagingSocketPath: string | null;
47
+ /** busy | idle | waiting, when the session says */
48
+ status: string | null;
49
+ }
50
+
51
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
52
+
53
+ /** Every readable registry file (`<config>/sessions/<pid>.json`); a torn or foreign one is skipped. */
54
+ export function readSessionFiles(dir: string = join(claudeHome(), "sessions")): SessionFile[] {
55
+ if (!existsSync(dir)) return [];
56
+ const out: SessionFile[] = [];
57
+ for (const f of readdirSync(dir)) {
58
+ if (!/^\d+\.json$/.test(f)) continue;
59
+ try {
60
+ const v = JSON.parse(readFileSync(join(dir, f), "utf8")) as Record<string, unknown>;
61
+ const sessionId = str(v.sessionId);
62
+ if (typeof v.pid !== "number" || !sessionId) continue;
63
+ out.push({
64
+ pid: v.pid,
65
+ sessionId,
66
+ name: str(v.name),
67
+ hostSessionId: str(v.hostSessionId),
68
+ messagingSocketPath: str(v.messagingSocketPath),
69
+ status: str(v.status),
70
+ });
71
+ } catch {
72
+ // written as we read it, or not Claude Code's
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * This server's session file: the one naming its socket, else its parent's pid (the MCP server is the session's
80
+ * child), else the one with its env's session id. Null when none is found.
81
+ */
82
+ export function sessionFileFor(
83
+ s: SessionEnv,
84
+ files = readSessionFiles(),
85
+ ppid = process.ppid,
86
+ ): SessionFile | null {
87
+ return (
88
+ (s.socketPath ? files.find((f) => f.messagingSocketPath === s.socketPath) : undefined) ??
89
+ files.find((f) => f.pid === ppid) ??
90
+ (s.sessionId ? files.find((f) => f.sessionId === s.sessionId) : undefined) ??
91
+ null
92
+ );
93
+ }
94
+
95
+ /** The live session that has `sessionId` now, if one does: its file names it and its process is alive. */
96
+ export function liveSessionFile(sessionId: string, files = readSessionFiles()): SessionFile | null {
97
+ return files.find((f) => f.sessionId === sessionId && isAlive(f.pid, null)) ?? null;
98
+ }
@@ -31,11 +31,19 @@ export function dispatchPaths(dir: string) {
31
31
  cancel: join(dir, "cancel"),
32
32
  /** held by the one supervisor of this dispatch for its lifetime (filelock, dead holders reclaimed) */
33
33
  supervisorLock: join(dir, "supervisor.lock"),
34
- /** exists from launch until a `wait` hands the dispatch's record to the orchestrator */
34
+ /** exists from admission until the orchestrator reads the record (`result`, `cancel`): "not yet read" */
35
35
  collect: join(dir, "collect"),
36
- /** while a `wait` hands the record back: names that wait's process, so a crash leaves it reclaimable */
36
+ /** while a reader takes the record: names that reader's process, so a crash leaves it reclaimable */
37
37
  lease: join(dir, "collect.lease"),
38
38
  supervisorLog: join(dir, "supervisor.log"),
39
+ /** what failover did for this limited dispatch, written once under `failover.lock` (plan 10) */
40
+ failover: join(dir, "failover.json"),
41
+ /** the message that announced this dispatch's record to its session, once sent (plan 10) */
42
+ notified: join(dir, "notified.json"),
43
+ /** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
44
+ stall: join(dir, "stall.json"),
45
+ /** the message that announced the stall, once sent */
46
+ stallNotified: join(dir, "stall-notified.json"),
39
47
  };
40
48
  }
41
49
 
@@ -81,7 +89,7 @@ export function readClaimant(dir: string): { pid: number; startTime: string | nu
81
89
  }
82
90
  }
83
91
 
84
- /** Marks a dispatch as one a `wait` is to hand back; written before its launch. */
92
+ /** Marks a dispatch's record as not yet read; written before its launch. */
85
93
  export function markForCollect(dir: string): void {
86
94
  writeFileSync(dispatchPaths(dir).collect, "", { mode: PRIVATE_FILE });
87
95
  }
@@ -130,8 +138,8 @@ function leaseDead(file: string): boolean {
130
138
  }
131
139
 
132
140
  /**
133
- * Whether a `wait` still has this dispatch's record to hand back: it has the mark, or a lease whose
134
- * collector died before handing the record back.
141
+ * Whether this dispatch's record is still unread: it has the mark, or a lease whose reader died before the
142
+ * record went out.
135
143
  */
136
144
  export function awaitsCollect(dir: string): boolean {
137
145
  const p = dispatchPaths(dir);
@@ -188,7 +196,7 @@ async function reviveDeadLease(dir: string): Promise<void> {
188
196
 
189
197
  /**
190
198
  * Collects the dispatch as a lease naming this process: true for the one caller that got it, so each
191
- * record reaches one `wait` only. The common path takes no lock: the lease is created exclusively, and
199
+ * record is read (marked read) once. The common path takes no lock: the lease is created exclusively, and
192
200
  * the record is this caller's only if the mark still exists then; the mark goes only once the lease
193
201
  * exists. A dead collector's lease is first turned back into the mark (`reviveDeadLease`), then
194
202
  * collected the same way; a live collector's is never taken. The caller ends the lease with `endCollect`
package/src/infra/env.ts CHANGED
@@ -1,5 +1,16 @@
1
- /** catherd's own secrets; the user's backend credentials (OPENAI_API_KEY, …) stay, workers need them. */
2
- const SECRET_ENV = new Set(["TYPESAFE_API_KEY"]);
1
+ /**
2
+ * catherd's own secrets; the user's backend credentials (OPENAI_API_KEY, …) stay, workers need them. The Claude Code
3
+ * session's messaging socket and token are the MCP server's alone (spec §3.2: it is the only sender), and so is the
4
+ * session's identity (a child that has it takes itself for that session: the doctor's MCP server would own its
5
+ * runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
6
+ */
7
+ const SECRET_ENV = new Set([
8
+ "TYPESAFE_API_KEY",
9
+ "CLAUDE_CODE_MESSAGING_SOCKET",
10
+ "CLAUDE_CODE_MESSAGING_TOKEN",
11
+ "CLAUDE_CODE_SESSION_ID",
12
+ "CLAUDE_CODE_HOST_SESSION_ID",
13
+ ]);
3
14
 
4
15
  /** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
5
16
  export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
@@ -8,6 +19,19 @@ export function scrubSecrets(base: Record<string, string | undefined>): Record<s
8
19
  return env;
9
20
  }
10
21
 
22
+ /**
23
+ * Spec 1.1 §12: the plugin's launcher runs `bunx` with TMPDIR in catherd's cache, so macOS never half-cleans
24
+ * the package, and passes the user's own TMPDIR in CATHERD_USER_TMPDIR (empty when unset). The server and
25
+ * every worker it starts get the user's TMPDIR back.
26
+ */
27
+ export function restoreTmpdir(env: Record<string, string | undefined>): void {
28
+ const mine = env.CATHERD_USER_TMPDIR;
29
+ if (mine === undefined) return;
30
+ if (mine) env.TMPDIR = mine;
31
+ else delete env.TMPDIR;
32
+ delete env.CATHERD_USER_TMPDIR;
33
+ }
34
+
11
35
  export function workerEnv(
12
36
  base: Record<string, string | undefined>,
13
37
  overrides: Record<string, string>,
@@ -0,0 +1,92 @@
1
+ import { createConnection } from "node:net";
2
+
3
+ /**
4
+ * Spec §3.4: one frame to a Claude Code session's peer inbox (docs/research/2026-09-28-cross-session-messaging.md).
5
+ * The auth line (the child token the session gave its children), then one `user` frame, in one write; the socket
6
+ * is closed 150 ms later, as the native client does on macOS. No `session_id` (a stale one drops the frame) and no
7
+ * `from-mode` (a wrong one holds it).
8
+ */
9
+
10
+ export type SendOutcome = "sent" | "no-session" | "refused" | "error";
11
+
12
+ export interface SendResult {
13
+ outcome: SendOutcome;
14
+ /** the frame's msg_id, when one was written */
15
+ msgId?: string;
16
+ /** why it was not sent */
17
+ reason?: string;
18
+ }
19
+
20
+ export interface InboxTarget {
21
+ socketPath: string | null;
22
+ token: string | null;
23
+ }
24
+
25
+ /** How long a connection may take, and how long after the write the socket is closed; tests shorten them. */
26
+ export const inboxLimits = { connectMs: 2_000, closeAfterMs: 150 };
27
+
28
+ /** The two lines one send writes: the auth line (when there is a token) and the user frame. */
29
+ export function frameText(
30
+ content: string,
31
+ priority: "later" | "next",
32
+ token: string | null,
33
+ msgId: string,
34
+ ): string {
35
+ const frame = { msgV: 1, msg_id: msgId, type: "user", message: { role: "user", content }, priority };
36
+ return `${token ? `${JSON.stringify({ type: "auth", token })}\n` : ""}${JSON.stringify(frame)}\n`;
37
+ }
38
+
39
+ /** Sends one message; never throws: every failure is an outcome with its reason. */
40
+ export function sendToInbox(
41
+ target: InboxTarget,
42
+ content: string,
43
+ priority: "later" | "next",
44
+ ): Promise<SendResult> {
45
+ if (!target.socketPath)
46
+ return Promise.resolve({
47
+ outcome: "no-session",
48
+ reason: "no messaging socket in this session's environment",
49
+ });
50
+ const msgId = crypto.randomUUID();
51
+ const socketPath = target.socketPath;
52
+ return new Promise<SendResult>((resolve) => {
53
+ let settled = false;
54
+ const done = (r: SendResult) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ resolve(r);
58
+ };
59
+ let s: ReturnType<typeof createConnection>;
60
+ try {
61
+ s = createConnection({ path: socketPath });
62
+ } catch (e) {
63
+ return done({ outcome: "error", reason: (e as Error).message });
64
+ }
65
+ const timer = setTimeout(() => {
66
+ s.destroy();
67
+ done({ outcome: "error", reason: `no connection within ${inboxLimits.connectMs} ms` });
68
+ }, inboxLimits.connectMs);
69
+ s.on("error", (e: NodeJS.ErrnoException) => {
70
+ clearTimeout(timer);
71
+ s.destroy();
72
+ if (e.code === "ENOENT")
73
+ done({ outcome: "no-session", reason: `the session's socket is gone (${socketPath})` });
74
+ else if (e.code === "ECONNREFUSED")
75
+ done({ outcome: "refused", reason: "the session's socket refused it" });
76
+ else done({ outcome: "error", reason: e.code ?? e.message });
77
+ });
78
+ s.on("connect", () => {
79
+ clearTimeout(timer);
80
+ s.write(frameText(content, priority, target.token, msgId), (err) => {
81
+ if (err) {
82
+ s.destroy();
83
+ return done({ outcome: "error", reason: err.message });
84
+ }
85
+ setTimeout(() => {
86
+ s.end();
87
+ done({ outcome: "sent", msgId });
88
+ }, inboxLimits.closeAfterMs);
89
+ });
90
+ });
91
+ });
92
+ }
@@ -189,13 +189,19 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
189
189
  const started = Date.now();
190
190
  let lastActivity = started;
191
191
  let finalAt: number | null = null;
192
+ // spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
193
+ let stalled = false;
194
+ let stallChecked = false;
192
195
  const open = new Set<string>();
193
196
  const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
194
197
 
195
198
  while (!done && reason === null) {
196
199
  await Bun.sleep(spec.pollMs);
197
200
  const lines = readNew(p.events, stream);
198
- if (lines.length) lastActivity = Date.now();
201
+ if (lines.length) {
202
+ lastActivity = Date.now();
203
+ stallChecked = false;
204
+ }
199
205
  for (const line of lines) {
200
206
  let d: LineInfo | undefined;
201
207
  try {
@@ -222,8 +228,32 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
222
228
  // the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
223
229
  // from the child itself too, since `done` is set by a callback that may not have run yet
224
230
  if (done || child.exitCode !== null || child.signalCode !== null) break;
225
- if (busy) lastActivity = Date.now();
226
- else reason = "idle-timeout";
231
+ if (busy) {
232
+ // a new quiet stretch starts: it gets its own stall check
233
+ lastActivity = Date.now();
234
+ stallChecked = false;
235
+ } else reason = "idle-timeout";
236
+ } else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
237
+ // asked once per quiet stretch: a busy answer starts a new quiet stretch, with its own half-way check,
238
+ // so a worker busy here and idle later is reported stalled before the idle check ends it
239
+ stallChecked = true;
240
+ const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
241
+ if (busy) {
242
+ lastActivity = Date.now();
243
+ stallChecked = false;
244
+ } else if (!done) {
245
+ stalled = true;
246
+ // advisory: a write that fails is logged and never ends a healthy worker
247
+ try {
248
+ writeJsonAtomic(p.stall, {
249
+ schema: 1,
250
+ at: new Date().toISOString(),
251
+ quietMs: Date.now() - lastActivity,
252
+ });
253
+ } catch (e) {
254
+ log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
255
+ }
256
+ }
227
257
  }
228
258
  }
229
259
  } catch (e) {
@@ -2,14 +2,17 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { BackendAdapter } from "../adapters/backend.ts";
4
4
  import { budgetStatus, formatBudget } from "../domain/budget.ts";
5
- import { CatherdError } from "../domain/errors.ts";
5
+ import { CatherdError, errorMessage } from "../domain/errors.ts";
6
6
  import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
7
- import { overlaps, parseLaneHeader } from "../domain/lane.ts";
7
+ import { assertLaneHeader, overlaps } from "../domain/lane.ts";
8
+ import type { RunRecord } from "../domain/record.ts";
9
+ import { withReplyContract } from "../domain/role-prompts.ts";
8
10
  import type { Role } from "../domain/roles.ts";
9
11
  import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
10
12
  import { withFileLock } from "../infra/filelock.ts";
11
13
  import { statusSnapshot } from "../infra/git.ts";
12
14
  import { launchSupervisor } from "../infra/launch.ts";
15
+ import { log } from "../infra/log.ts";
13
16
  import { processStartTime } from "../infra/proc.ts";
14
17
  import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
15
18
  import { readyAdapter, standInFor } from "./backends.ts";
@@ -28,6 +31,7 @@ import {
28
31
  import { finalizeDispatch } from "./finalize.ts";
29
32
  import type { Deps } from "./ports.ts";
30
33
  import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
34
+ import { currentSession } from "./sessions.ts";
31
35
 
32
36
  export interface AdmitInput {
33
37
  role: Role;
@@ -39,6 +43,8 @@ export interface AdmitInput {
39
43
  failoverFrom: string | null;
40
44
  /** the dispatch id of the limited dispatch this stand-in replaces */
41
45
  failoverOf?: string;
46
+ /** the dispatching session (spec §3.3); absent means the session this process serves */
47
+ sessionId?: string | null;
42
48
  }
43
49
 
44
50
  /** Spec §3.3: SIGTERM, then SIGKILL this long after. */
@@ -52,7 +58,7 @@ function laneOwns(run: Run, lane: string): string[] {
52
58
  throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
53
59
  fix: "write it with write_run_file first",
54
60
  });
55
- const owns = parseLaneHeader(readFileSync(file, "utf8")).owns;
61
+ const owns = assertLaneHeader(readFileSync(file, "utf8"), `lanes/${lane}.md`).owns;
56
62
  if (owns.length === 0)
57
63
  throw new CatherdError("E_LANE_INVALID", `lanes/${lane}.md has no Owns: line`, {
58
64
  fix: "add `Owns: <paths>` below the lane's title",
@@ -60,18 +66,29 @@ function laneOwns(run: Run, lane: string): string[] {
60
66
  return owns;
61
67
  }
62
68
 
69
+ /** What a caller of `admit` does with each record `admit` itself writes (dispatch settles it). */
70
+ export type OnRecorded = (d: Dispatch, record: RunRecord) => Promise<unknown>;
71
+
63
72
  /**
64
73
  * Records each finished dispatch of the run that has none yet, as reconcile does, so one whose waiter died
65
- * never blocks its name or lane. Before the admission lock: a finalize may wait on another's claim. One that
66
- * throws is skipped and stays pending, so the refusal still names it.
74
+ * never blocks its name or lane, and hands each record to `onRecorded` (the caller settles it, so its owner is
75
+ * told). Before the admission lock: a finalize may wait on another's claim. One that throws is skipped and
76
+ * stays pending, so the refusal still names it; an `onRecorded` that throws never fails the admit.
67
77
  */
68
- async function finalizeFinished(run: Run, now: number): Promise<void> {
78
+ async function finalizeFinished(run: Run, now: number, onRecorded?: OnRecorded): Promise<void> {
69
79
  for (const d of pendingDispatches(run, now)) {
70
80
  if (d.state !== "finished") continue;
81
+ let record: RunRecord;
71
82
  try {
72
- await finalizeDispatch(run, d);
83
+ record = await finalizeDispatch(run, d);
73
84
  } catch {
74
85
  // stays pending: admission refuses its name and lane, and names it
86
+ continue;
87
+ }
88
+ try {
89
+ await onRecorded?.(d, record);
90
+ } catch (e) {
91
+ log("warn", "admit", { run: run.id, name: d.admit.name, error: errorMessage(e) });
75
92
  }
76
93
  }
77
94
  }
@@ -109,7 +126,12 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
109
126
  * Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
110
127
  * happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
111
128
  */
112
- export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: Dispatch; specPath: string }> {
129
+ export async function admit(
130
+ deps: Deps,
131
+ run: Run,
132
+ i: AdmitInput,
133
+ onRecorded?: OnRecorded,
134
+ ): Promise<{ d: Dispatch; specPath: string }> {
113
135
  assertId("role name", i.name);
114
136
  if (i.lane !== null) assertId("lane", i.lane);
115
137
  const rung = parseRung(i.rung);
@@ -151,6 +173,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
151
173
  const plan = adapter.plan({
152
174
  rung,
153
175
  access: rc.access,
176
+ network: rc.network !== false,
154
177
  thread: i.thread,
155
178
  isolated,
156
179
  repo: run.meta.repo,
@@ -159,7 +182,8 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
159
182
  dispatchDir: dir,
160
183
  });
161
184
 
162
- await finalizeFinished(run, deps.now());
185
+ await finalizeFinished(run, deps.now(), onRecorded);
186
+ const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
163
187
  return withFileLock(runPaths(run.dir).admission, async () => {
164
188
  // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
165
189
  // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
@@ -179,7 +203,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
179
203
  fix:
180
204
  same.state === "finished"
181
205
  ? `its record could not be written; see ${same.dir}, and retry`
182
- : `wait for it, or cancel(run, "${i.name}")`,
206
+ : `its record is announced when it finishes; or cancel(run, "${i.name}")`,
183
207
  },
184
208
  );
185
209
  for (const d of pending) {
@@ -219,9 +243,11 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
219
243
  admittedAt: new Date(deps.now()).toISOString(),
220
244
  repo: run.meta.repo,
221
245
  before: await statusSnapshot(run.meta.repo),
246
+ ...(sessionId ? { sessionId } : {}),
222
247
  };
223
248
  ensurePrivateDir(dir);
224
- writeTextAtomic(p.brief, i.brief);
249
+ // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
250
+ writeTextAtomic(p.brief, withReplyContract(i.role, i.brief));
225
251
  // Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
226
252
  // time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
227
253
  writeJsonAtomic(
@@ -244,7 +270,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
244
270
  { mode: 0o600 },
245
271
  );
246
272
  // the collect mark before admit.json: no admitted dispatch ever exists without it, so a process that
247
- // dies before launching it still leaves a record (lost, once its start grace passes) for a `wait`
273
+ // dies before launching it still leaves a record (lost, once its start grace passes), unread
248
274
  markForCollect(dir);
249
275
  writeJsonAtomic(admitPath(dir), admitted);
250
276
  setLatest(run, i.name, id);