catherd-cli 1.0.0 → 1.1.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 (82) 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 +63 -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/state.ts +26 -6
  44. package/src/entry/tui/stories.ts +36 -4
  45. package/src/entry/tui/text.ts +12 -0
  46. package/src/entry/tui/theme.ts +1 -0
  47. package/src/entry/tui/views/runs.tsx +409 -117
  48. package/src/entry/tui/views/status.tsx +5 -2
  49. package/src/entry/tui/widgets/list.tsx +4 -3
  50. package/src/infra/claude-session.ts +98 -0
  51. package/src/infra/dispatch-dir.ts +14 -6
  52. package/src/infra/env.ts +26 -2
  53. package/src/infra/peer-inbox.ts +92 -0
  54. package/src/infra/supervisor.ts +33 -3
  55. package/src/services/admission.ts +38 -12
  56. package/src/services/dispatch-service.ts +338 -197
  57. package/src/services/dispatches.ts +30 -1
  58. package/src/services/doctor-access.ts +231 -0
  59. package/src/services/doctor-backends.ts +3 -18
  60. package/src/services/doctor-checks.ts +46 -1
  61. package/src/services/doctor-push.ts +109 -0
  62. package/src/services/doctor.ts +59 -79
  63. package/src/services/finalize.ts +43 -25
  64. package/src/services/gate-service.ts +288 -0
  65. package/src/services/global-install.ts +99 -0
  66. package/src/services/lane-service.ts +150 -8
  67. package/src/services/milestones.ts +208 -0
  68. package/src/services/notifier.ts +296 -0
  69. package/src/services/peek.ts +103 -0
  70. package/src/services/ports.ts +7 -4
  71. package/src/services/preflight.ts +17 -3
  72. package/src/services/protocol.ts +212 -0
  73. package/src/services/questions.ts +102 -0
  74. package/src/services/reconcile.ts +40 -11
  75. package/src/services/reentry.ts +22 -0
  76. package/src/services/run-service.ts +42 -9
  77. package/src/services/run-store.ts +18 -0
  78. package/src/services/runs-page.ts +254 -0
  79. package/src/services/session-view.ts +152 -0
  80. package/src/services/sessions.ts +92 -0
  81. package/src/services/state.ts +10 -3
  82. package/src/services/summary.ts +14 -0
@@ -0,0 +1,57 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ID_PATTERN } from "../../domain/ids.ts";
4
+ import { gateCheck, gatePass } from "../../services/gate-service.ts";
5
+ import { answer, park } from "../../services/questions.ts";
6
+ import type { Deps } from "../../services/ports.ts";
7
+ import { handle } from "./result.ts";
8
+
9
+ // Spec 1.1 §7 and §8: the tools the enforced protocol adds.
10
+ export function registerProtocolTools(server: McpServer, deps: Deps): void {
11
+ const gate = {
12
+ run: z.string(),
13
+ item: z.string().min(1),
14
+ command: z.string().min(1),
15
+ paths: z.array(z.string().min(1)).min(1),
16
+ };
17
+
18
+ server.registerTool(
19
+ "gate_check",
20
+ {
21
+ description:
22
+ "The verifier, before running a gate item: { carried: true, passedAt, commit } when this repo already has a pass of the same command on the same content of paths (repo-relative; . for the whole repo: HEAD plus uncommitted not-ignored changes; a git-ignored input such as .env or a build output is hashed only when named as a path), so it reports the item as carried over from that commit instead of running it; else { carried: false }, and it runs the item. Either way the call is recorded as the verifier's current step, which status and peek show. Pass milestone (the M the verifier checks) so the milestone's digest lists only its own carried items.",
23
+ inputSchema: { ...gate, milestone: z.string().regex(ID_PATTERN).optional() },
24
+ },
25
+ (a) => handle(() => gateCheck(deps, a)),
26
+ );
27
+
28
+ server.registerTool(
29
+ "gate_pass",
30
+ {
31
+ description:
32
+ "The verifier, after a gate item passed: records the command, the content hash of paths and the commit, with the evidence (a log path or the decisive line), so a later gate_check on unchanged content carries it over.",
33
+ inputSchema: { ...gate, evidence: z.string().min(1) },
34
+ },
35
+ (a) => handle(() => gatePass(deps, a)),
36
+ );
37
+
38
+ server.registerTool(
39
+ "park",
40
+ {
41
+ description:
42
+ "An owner question only the user can answer: parks the milestone (land refuses it until answered) and puts it in front of state.md's next step, instead of stopping the run. Returns the parked milestones and hints: push the full question to the user with PushNotification, and go on with the milestones and runs that do not depend on it.",
43
+ inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), question: z.string().min(1) },
44
+ },
45
+ (a) => handle(() => park(deps, a)),
46
+ );
47
+
48
+ server.registerTool(
49
+ "answer",
50
+ {
51
+ description:
52
+ "The owner answered a parked milestone's question in this session: records the answer and unparks the milestone. status, peek and run_start list the questions still open.",
53
+ inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), answer: z.string().min(1) },
54
+ },
55
+ (a) => handle(() => answer(deps, a)),
56
+ );
57
+ }
@@ -22,7 +22,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
22
22
  "run_start",
23
23
  {
24
24
  description:
25
- "Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id and the folder, and hints when state.md could not be written yet.",
25
+ "Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id, the folder, protocol (the next step of the milestone loop and its six-line checklist), and hints when state.md could not be written yet.",
26
26
  inputSchema: {
27
27
  repo: z.string().min(1),
28
28
  title: z.string().min(1),
@@ -68,7 +68,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
68
68
  server.registerTool(
69
69
  "result",
70
70
  {
71
- description: "A role's latest dispatch: its state, its record once finished, and its capped reply.",
71
+ description:
72
+ "A role's latest dispatch: its state, its record once finished, its capped reply, and hints (climb, violation, failed, limit and failover lines). Reading a finished record marks it read: catherd's messages announce records, and this is how you read one.",
72
73
  inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
73
74
  },
74
75
  (a) => handle(() => result(deps, a)),
@@ -88,7 +89,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
88
89
  "record_agent_run",
89
90
  {
90
91
  description:
91
- "After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings.",
92
+ "After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings. status says how the subagent's work ended: for a verifier it is the verdict, so pass status: \"failed\" when its verdict is FAIL; only a PASS is recorded ok.",
92
93
  inputSchema: {
93
94
  run: z.string(),
94
95
  name: z.string().regex(ID_PATTERN),
@@ -3,11 +3,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { errorMessage } from "../../domain/errors.ts";
5
5
  import { log } from "../../infra/log.ts";
6
+ import { startNotifier } from "../../services/notifier.ts";
6
7
  import type { Deps } from "../../services/ports.ts";
7
8
  import { reconcileAll } from "../../services/reconcile.ts";
8
9
  import { defaultDeps } from "../deps.ts";
9
10
  import { registerDispatchTools } from "./dispatch-tools.ts";
10
11
  import { registerLaneTools } from "./lane-tools.ts";
12
+ import { registerProtocolTools } from "./protocol-tools.ts";
11
13
  import { sdkToolError, toolOf } from "./result.ts";
12
14
  import { registerRunTools } from "./run-tools.ts";
13
15
  import { registerSetupTools } from "./setup-tools.ts";
@@ -52,12 +54,24 @@ export function buildServer(deps: Deps = defaultDeps()): McpServer {
52
54
  registerLaneTools(server, deps);
53
55
  registerDispatchTools(server, deps);
54
56
  registerSetupTools(server, deps);
57
+ registerProtocolTools(server, deps);
55
58
  return server;
56
59
  }
57
60
 
58
- /** Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. */
61
+ /**
62
+ * Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
63
+ * starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done.
64
+ */
59
65
  export async function startMcpServer(): Promise<void> {
60
66
  const deps = defaultDeps();
67
+ // which of the session's variables this server got (never their values): the live check of spec §3.9
68
+ log("info", "session", {
69
+ sessionId: Boolean(deps.session?.sessionId),
70
+ hostSessionId: Boolean(deps.session?.hostSessionId),
71
+ socket: Boolean(deps.session?.socketPath),
72
+ token: Boolean(deps.session?.token),
73
+ });
74
+ const notifier = startNotifier(deps);
61
75
  await buildServer(deps).connect(new StdioServerTransport());
62
76
  try {
63
77
  const r = await reconcileAll(deps);
@@ -69,4 +83,6 @@ export async function startMcpServer(): Promise<void> {
69
83
  } catch (e) {
70
84
  console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
71
85
  }
86
+ // whatever finished unread while no server ran, or finished under another server (spec §3.4)
87
+ void notifier.scan();
72
88
  }
@@ -73,7 +73,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
73
73
  "profile_set",
74
74
  {
75
75
  description:
76
- "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
76
+ "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
77
77
  inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
78
78
  },
79
79
  (a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
@@ -40,7 +40,7 @@ export interface StandIn {
40
40
  via: string | null;
41
41
  }
42
42
 
43
- /** Spec §7.2: each stand-in, marked inferred when its scores are catherd's guess. */
43
+ /** Spec §7.2: each stand-in; `via` names the rung whose scores it borrows (spec 1.1 §11). */
44
44
  export function standIns(p: Profile, c: Catalog): StandIn[] {
45
45
  return Object.entries(p.failover).map(([from, to]) => {
46
46
  try {
@@ -83,7 +83,7 @@ export function formatProfile(
83
83
  }
84
84
  const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
85
85
  lines.push(
86
- ` ${role.padEnd(width)} ${`${rc.access}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
86
+ ` ${role.padEnd(width)} ${`${rc.access}${rc.network === false ? " (no network)" : ""}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
87
87
  );
88
88
  }
89
89
  // only what catherd can run today: a backend without an adapter has nothing to bill or isolate
@@ -94,9 +94,7 @@ export function formatProfile(
94
94
  lines.push(`harness ${harness.map(([k, h]) => `${k} ${h.isolated ? "isolated" : "native"}`).join(" · ")}`);
95
95
  lines.push(o.standIns.length ? "failover" : "failover none");
96
96
  for (const s of o.standIns)
97
- lines.push(
98
- ` ${s.from} → ${s.to}${s.inferred ? ` (inferred${s.via ? `: treated like ${s.via}` : ""})` : ""}`,
99
- );
97
+ lines.push(` ${s.from} → ${s.to}${s.via ? ` (scores borrowed from ${s.via})` : ""}`);
100
98
  const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
101
99
  lines.push(`budget ${budget.join(" · ") || "no cap"}`);
102
100
  lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
@@ -7,7 +7,8 @@ import { redact } from "../infra/log.ts";
7
7
  import { cancel } from "../services/dispatch-service.ts";
8
8
  import { registerSavedSecrets } from "../services/jev-service.ts";
9
9
  import { runDebug } from "../services/run-debug.ts";
10
- import { findRun, listRuns, readRecords } from "../services/run-store.ts";
10
+ import { findRun, listRuns, readRecords, type Run } from "../services/run-store.ts";
11
+ import { groupRuns, type RunSession, type SessionGroup } from "../services/session-view.ts";
11
12
  import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
12
13
  import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
13
14
  import { defaultDeps } from "./deps.ts";
@@ -15,6 +16,15 @@ import { defaultDeps } from "./deps.ts";
15
16
  const json = JSON_ARG;
16
17
  const n = (x: number) => x.toLocaleString("en-US");
17
18
 
19
+ /** A session's heading in `runs list` and `status` (spec §4): `● live <name>` or `· idle <name>`. */
20
+ export function sessionHeading(s: RunSession | null): string {
21
+ return s ? `session ${s.live ? "● live" : "· idle"} ${s.name}` : "earlier runs";
22
+ }
23
+
24
+ /** How a run moved between sessions, after its line: `(continued here)`, `(continued in <name>)`. */
25
+ const movedNote = (g: SessionGroup["runs"][number]): string =>
26
+ g.continued === "here" ? " (continued here)" : g.continuedIn ? ` (continued in ${g.continuedIn})` : "";
27
+
18
28
  /** One run at a glance: what is live, what finished, spend against the budget, landed milestones. */
19
29
  export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
20
30
  const t = s.totals;
@@ -22,6 +32,7 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
22
32
  `run ${s.id} ${s.title}`,
23
33
  ` repo ${s.repo} · started ${s.createdAt} · ${Math.round((now - Date.parse(s.createdAt)) / 60_000)} min`,
24
34
  ];
35
+ for (const q of s.questions) lines.push(` ${mark("warn")} parked ${q.milestone}: ${q.question}`);
25
36
  for (const l of s.live) lines.push(` live ${l.name} ${l.rung} ${l.state} ${l.secs}s`);
26
37
  lines.push(
27
38
  ` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
@@ -37,6 +48,10 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
37
48
  if (s.budget) lines.push(` budget ${formatBudget(s.budget)}`);
38
49
  if (s.jev.decisions) lines.push(` jev ${s.jev.decisions} decision(s), ${s.jev.fallbacks} fallback(s)`);
39
50
  for (const m of s.milestones) lines.push(` landed ${m}`);
51
+ if (s.verifier)
52
+ lines.push(
53
+ ` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)}`,
54
+ );
40
55
  for (const l of s.stateTail) lines.push(` | ${l}`);
41
56
  for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
42
57
  return lines;
@@ -48,7 +63,27 @@ function printStatus(runId: string | undefined, asJson: boolean): void {
48
63
  const r = redact(status(defaultDeps(), runId));
49
64
  if (asJson) return printJson(r);
50
65
  if (r.runs.length === 0) console.log("no runs yet");
51
- for (const s of r.runs) for (const l of formatRun(s)) console.log(l);
66
+ // grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
67
+ const byId = new Map(r.runs.map((s) => [s.id, s]));
68
+ const runs = r.runs.flatMap((s) => {
69
+ try {
70
+ return [findRun(s.id)];
71
+ } catch {
72
+ return [];
73
+ }
74
+ });
75
+ for (const g of groupRuns(runs)) {
76
+ const own = g.runs.filter((x) => x.continued !== "here");
77
+ if (own.length === 0) continue;
78
+ console.log(sessionHeading(g.session));
79
+ for (const x of own) {
80
+ const s = byId.get(x.run.id);
81
+ if (!s) continue;
82
+ const [head, ...rest] = formatRun(s);
83
+ console.log(`${head}${movedNote(x)}`);
84
+ for (const l of rest) console.log(l);
85
+ }
86
+ }
52
87
  for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
53
88
  }
54
89
 
@@ -124,25 +159,33 @@ const list = defineCommand({
124
159
  fix: "pass a path inside the repo, or leave out --repo",
125
160
  });
126
161
  const { runs, corrupt } = listRuns();
127
- const rows = runs
128
- .filter((r) => !args.repo || r.meta.repo === top)
129
- .map((r) => {
130
- const s = summarizeRun(defaultDeps(), r);
131
- return {
132
- id: r.id,
133
- title: r.meta.title,
134
- repo: r.meta.repo,
135
- createdAt: r.meta.createdAt,
136
- live: s.live.length,
137
- roleRuns: s.totals.runs,
138
- };
139
- });
140
- if (args.json) return printJson({ runs: rows, corrupt });
141
- if (rows.length === 0) console.log("no runs yet");
142
- for (const r of rows)
143
- console.log(
144
- `${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}`,
145
- );
162
+ const shown = runs.filter((r) => !args.repo || r.meta.repo === top);
163
+ const deps = defaultDeps();
164
+ const row = (r: Run) => {
165
+ const s = summarizeRun(deps, r);
166
+ return {
167
+ id: r.id,
168
+ title: r.meta.title,
169
+ repo: r.meta.repo,
170
+ createdAt: r.meta.createdAt,
171
+ live: s.live.length,
172
+ roleRuns: s.totals.runs,
173
+ session: s.session,
174
+ continuedIn: s.continuedIn,
175
+ };
176
+ };
177
+ const groups = groupRuns(shown);
178
+ if (args.json) return printJson({ runs: shown.map(row), corrupt });
179
+ if (shown.length === 0) console.log("no runs yet");
180
+ for (const g of groups) {
181
+ console.log(sessionHeading(g.session));
182
+ for (const x of g.runs) {
183
+ const r = row(x.run);
184
+ console.log(
185
+ ` ${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}${movedNote(x)}`,
186
+ );
187
+ }
188
+ }
146
189
  for (const c of corrupt) console.log(`${mark("warn")} skipped run ${c.id}: ${c.reason}`);
147
190
  },
148
191
  });
@@ -314,7 +314,7 @@ export const COMMANDS = [
314
314
  },
315
315
  {
316
316
  id: "runs.open",
317
- title: "Open the run",
317
+ title: "Open the session, milestone or role",
318
318
  group: "Runs",
319
319
  scope: "row.runs",
320
320
  keys: ["return"],
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { realpathSync, statSync, watch } from "node:fs";
2
2
  import { adapterFor } from "../../adapters/registry.ts";
3
3
  import "../../adapters/all.ts";
4
4
  import { agentFiles } from "../../domain/agents.ts";
@@ -6,7 +6,6 @@ import type { Catalog } from "../../domain/catalog.ts";
6
6
  import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
7
7
  import { type Validation, validateProfile } from "../../domain/profile-rules.ts";
8
8
  import type { Access } from "../../domain/record.ts";
9
- import type { RouteSource } from "../../domain/route.ts";
10
9
  import { VERSION } from "../../infra/version.ts";
11
10
  import {
12
11
  type CatalogModel,
@@ -37,13 +36,32 @@ import {
37
36
  readProjects,
38
37
  runnableBackends,
39
38
  } from "../../services/profile-store.ts";
40
- import { findRun, listRuns, readRoutes, type Run, runPaths } from "../../services/run-store.ts";
39
+ import { listRuns, type Run, runPaths } from "../../services/run-store.ts";
40
+ import {
41
+ type MilestoneDetail,
42
+ milestoneDetail,
43
+ type RoleDetail,
44
+ roleDetail,
45
+ type SessionDetail,
46
+ sessionDetail,
47
+ type SessionRow,
48
+ sessionRows,
49
+ } from "../../services/runs-page.ts";
41
50
  import { type RunSummary, summarizeRun } from "../../services/summary.ts";
42
51
  import { defaultDeps } from "../deps.ts";
43
52
  import { mcpHandshake } from "../mcp/handshake.ts";
44
53
 
45
54
  /** A save refused because the profile changed on disk since its preview read it carries this path. */
46
55
  export { CHANGED_ON_DISK } from "../../services/profile-service.ts";
56
+ export type {
57
+ Milestone,
58
+ MilestoneDetail,
59
+ RoleDetail,
60
+ RoleRow,
61
+ SessionDetail,
62
+ SessionRow,
63
+ SessionRun,
64
+ } from "../../services/runs-page.ts";
47
65
 
48
66
  /** One line of the run list. */
49
67
  export interface RunRow {
@@ -56,31 +74,8 @@ export interface RunRow {
56
74
  landed: number;
57
75
  /** the budget's spent fraction, null without a cap */
58
76
  budget: number | null;
59
- }
60
-
61
- interface Climb {
62
- lane: string;
63
- from: string;
64
- to: string;
65
- reason: string;
66
- /** the environment's fault (a limit, a missing service), not the rung's */
67
- env: boolean;
68
- }
69
-
70
- interface Decision {
71
- lane: string;
72
- role: string;
73
- source: RouteSource;
74
- kind: string | null;
75
- difficulty: string | null;
76
- rung: string;
77
- }
78
-
79
- /** Spec §9.1's run view: live lanes, climbs, Jev decisions, budget, landed milestones. */
80
- export interface RunDetail {
81
- summary: RunSummary;
82
- climbs: Climb[];
83
- decisions: Decision[];
77
+ /** the session that started it (the Runs tab opens it); null for a run from before 1.1 */
78
+ session: string | null;
84
79
  }
85
80
 
86
81
  /**
@@ -91,7 +86,19 @@ export interface Effects {
91
86
  version: string;
92
87
  doctor(): Promise<DoctorReport>;
93
88
  runs(): { rows: RunRow[]; warnings: string[] };
94
- run(id: string): RunDetail;
89
+ /** spec §4: the Runs tab's top level, the sessions */
90
+ sessions(): { rows: SessionRow[]; warnings: string[] };
91
+ /** a session's screen; `key` null is "earlier runs" */
92
+ session(key: string | null): SessionDetail;
93
+ /** a role's screen */
94
+ role(run: string, dispatchId: string): RoleDetail;
95
+ /** spec 1.1 §10: a milestone's screen, its digest */
96
+ milestone(run: string, name: string): MilestoneDetail;
97
+ /**
98
+ * calls `onChange` when a file under one of `dirs` changes (spec §4: the open screen redraws when a run file
99
+ * changes); null when they cannot be watched here, and the screen polls every second instead
100
+ */
101
+ watch(dirs: string[], onChange: () => void): (() => void) | null;
95
102
  /** stops a live role; resolves to the line the toast shows */
96
103
  cancel(run: string, name: string): Promise<string>;
97
104
  profiles(): {
@@ -193,33 +200,67 @@ export function rowOf(s: RunSummary): RunRow {
193
200
  roleRuns: s.totals.runs,
194
201
  landed: s.milestones.length,
195
202
  budget: s.budget?.fraction ?? null,
203
+ session: s.session?.sessionId ?? null,
196
204
  };
197
205
  }
198
206
 
199
- /** The climbs and route decisions in a run's routes.jsonl. */
200
- function routesOf(run: Run): { climbs: Climb[]; decisions: Decision[] } {
201
- const rows = readRoutes(run);
202
- return {
203
- climbs: rows
204
- .filter((r) => r.source === "climb" && r.from !== null)
205
- .map((r) => ({
206
- lane: r.lane,
207
- from: r.from as string,
208
- to: r.rung,
209
- reason: r.reason ?? "",
210
- env: r.env === true,
211
- })),
212
- decisions: rows
213
- .filter((r) => r.source === "route")
214
- .map((r) => ({
215
- lane: r.lane,
216
- role: r.role,
217
- source: r.decidedBy,
218
- kind: r.kind,
219
- difficulty: r.difficulty,
220
- rung: r.rung,
221
- })),
207
+ /** How long a burst of file events is gathered into one redraw. */
208
+ const WATCH_SETTLE_MS = 100;
209
+
210
+ /** What `watchDirs` watches and times with; tests pass fakes. */
211
+ export interface WatchPorts {
212
+ watch(dir: string, fire: () => void): { on(event: "error", f: () => void): unknown; close(): void };
213
+ setTimeout(f: () => void, ms: number): unknown;
214
+ clearTimeout(h: unknown): void;
215
+ /** the folder's real path: macOS reports FSEvents under it (/var is /private/var) */
216
+ realpath(dir: string): string;
217
+ }
218
+
219
+ const LIVE_WATCH: WatchPorts = {
220
+ watch: (dir, fire) => watch(dir, { recursive: true }, fire),
221
+ setTimeout: (f, ms) => setTimeout(f, ms),
222
+ clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
223
+ realpath: (dir) => realpathSync(dir),
224
+ };
225
+
226
+ /**
227
+ * `fs.watch` on each run folder's real path, recursive; null when any of them cannot be watched. After the
228
+ * returned stop, `onChange` is never called, even for an event already queued.
229
+ */
230
+ export function watchDirs(
231
+ dirs: string[],
232
+ onChange: () => void,
233
+ ports: Partial<WatchPorts> = {},
234
+ ): (() => void) | null {
235
+ const io = { ...LIVE_WATCH, ...ports };
236
+ const watchers: { close(): void }[] = [];
237
+ let timer: unknown = null;
238
+ let stopped = false;
239
+ const fire = () => {
240
+ if (stopped || timer !== null) return;
241
+ timer = io.setTimeout(() => {
242
+ timer = null;
243
+ if (!stopped) onChange();
244
+ }, WATCH_SETTLE_MS);
222
245
  };
246
+ const stop = () => {
247
+ stopped = true;
248
+ if (timer !== null) io.clearTimeout(timer);
249
+ timer = null;
250
+ for (const w of watchers) w.close();
251
+ };
252
+ try {
253
+ for (const d of dirs) {
254
+ const w = io.watch(io.realpath(d), fire);
255
+ // a watcher that breaks later (the folder removed) just stops; the screen's slow poll still reads
256
+ w.on("error", () => w.close());
257
+ watchers.push(w);
258
+ }
259
+ } catch {
260
+ stop();
261
+ return null;
262
+ }
263
+ return stop;
223
264
  }
224
265
 
225
266
  /**
@@ -238,10 +279,11 @@ export function liveEffects(repo: string | null = null): Effects {
238
279
  const { runs, corrupt } = listRuns();
239
280
  return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
240
281
  },
241
- run(id) {
242
- const run = findRun(id);
243
- return { summary: summarizeRun(deps, run), ...routesOf(run) };
244
- },
282
+ sessions: () => sessionRows(deps),
283
+ session: (key) => sessionDetail(deps, key),
284
+ role: (run, dispatchId) => roleDetail(deps, run, dispatchId),
285
+ milestone: (run, name) => milestoneDetail(run, name),
286
+ watch: watchDirs,
245
287
  async cancel(run, name) {
246
288
  const r = await cancel(deps, run, name);
247
289
  return `${r.record.name} ${r.record.status}`;