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
@@ -8,9 +8,18 @@ import {
8
8
  } from "../../domain/profile.ts";
9
9
  import { validateProfile } from "../../domain/profile-rules.ts";
10
10
  import { catalogQuery, loadCatalog } from "../../services/catalog-service.ts";
11
+ import type { RunRecord } from "../../domain/record.ts";
11
12
  import type { DoctorReport } from "../../services/doctor.ts";
12
13
  import type { RunSummary } from "../../services/summary.ts";
13
- import { CHANGED_ON_DISK, type Effects, type RunDetail, type RunRow, rowOf } from "./effects.ts";
14
+ import {
15
+ CHANGED_ON_DISK,
16
+ type Effects,
17
+ type RoleDetail,
18
+ type RoleRow,
19
+ rowOf,
20
+ type SessionRow,
21
+ type SessionRun,
22
+ } from "./effects.ts";
14
23
  import { withStaged } from "./profile-tree.ts";
15
24
 
16
25
  /**
@@ -70,6 +79,8 @@ export const FIXTURE_REPORT: DoctorReport = {
70
79
  const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): RunSummary => ({
71
80
  repo: "/home/me/app",
72
81
  createdAt: "2026-09-26T11:48:00.000Z",
82
+ session: null,
83
+ continuedIn: null,
73
84
  stateTail: [],
74
85
  live: [],
75
86
  totals: {
@@ -85,93 +96,294 @@ const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): Run
85
96
  harness: [],
86
97
  budget: null,
87
98
  milestones: [],
99
+ verifier: null,
100
+ questions: [],
88
101
  warnings: [],
89
102
  ...o,
90
103
  });
91
104
 
92
- const FIXTURE_RUNS: RunDetail[] = [
93
- {
94
- summary: summary({
95
- id: "20260926-114800-jobs-screen",
96
- title: "Jobs screen",
97
- stateTail: ["M1 in review", "Next: fix round for M1.L2"],
98
- live: [
99
- {
100
- name: "worker-M1.L2",
101
- rung: "codex:gpt-6-sol#medium",
102
- state: "running",
103
- secs: 252,
104
- dispatchId: "d2",
105
- },
106
- { name: "reviewer-M1", rung: "codex:gpt-6-sol#high", state: "starting", secs: 4, dispatchId: "d3" },
107
- ],
108
- totals: {
109
- runs: 3,
110
- ok: 3,
111
- notOk: [],
112
- tokens: { input: 812_000, cached: 640_000, output: 41_000 },
113
- costUsd: 0,
114
- wallMinutes: 12,
115
- },
116
- jev: { decisions: 2, fallbacks: 0 },
117
- budget: { fraction: 0.52, minutes: { spent: 31, cap: 60 } },
118
- milestones: ["M0 | scaffold the jobs screen | 3f2a9c1 | 9 | bun test test/jobs"],
119
- }),
120
- climbs: [
105
+ const JOBS = "20260926-114800-jobs-screen";
106
+ const AUTH = "20260925-090000-auth-refactor";
107
+ const PLAN4 = "20260924-080000-auth-plan-4";
108
+
109
+ const FIXTURE_RUNS: RunSummary[] = [
110
+ summary({
111
+ id: JOBS,
112
+ title: "Jobs screen",
113
+ session: { sessionId: "s-jobs", hostSessionId: null, name: "jobs screen", live: true },
114
+ stateTail: ["M1 in review", "Next: fix round for M1.L2"],
115
+ live: [
121
116
  {
122
- lane: "M1.L2",
123
- from: "codex:gpt-6-luna#high",
124
- to: "codex:gpt-6-sol#medium",
125
- reason: "refused",
126
- env: false,
117
+ name: "worker-M1.L2",
118
+ rung: "codex:gpt-6-sol#medium",
119
+ state: "running",
120
+ secs: 252,
121
+ dispatchId: "d2",
127
122
  },
123
+ { name: "reviewer-M1", rung: "codex:gpt-6-sol#high", state: "starting", secs: 4, dispatchId: "d3" },
128
124
  ],
129
- decisions: [
130
- {
131
- lane: "M1.L1",
132
- role: "worker",
133
- source: "lane",
134
- kind: "repo_code",
135
- difficulty: "copy",
136
- rung: "codex:gpt-6-luna#high",
137
- },
125
+ totals: {
126
+ runs: 3,
127
+ ok: 3,
128
+ notOk: [],
129
+ tokens: { input: 812_000, cached: 640_000, output: 41_000 },
130
+ costUsd: 0,
131
+ wallMinutes: 12,
132
+ },
133
+ jev: { decisions: 2, fallbacks: 0 },
134
+ budget: { fraction: 0.52, minutes: { spent: 31, cap: 60 } },
135
+ milestones: ["M0 | scaffold the jobs screen | 3f2a9c1 | 9 | bun test test/jobs"],
136
+ }),
137
+ summary({
138
+ id: AUTH,
139
+ title: "Auth refactor",
140
+ repo: "/home/me/api",
141
+ createdAt: "2026-09-25T09:00:00.000Z",
142
+ session: { sessionId: "s-auth", hostSessionId: null, name: "auth build", live: false },
143
+ continuedIn: "kit follow-up",
144
+ stateTail: ["Next: done"],
145
+ totals: {
146
+ runs: 9,
147
+ ok: 8,
148
+ notOk: ["worker-M2.L1 (limit)"],
149
+ tokens: { input: 3_100_000, cached: 2_400_000, output: 160_000 },
150
+ costUsd: 1.84,
151
+ wallMinutes: 96,
152
+ },
153
+ milestones: ["M1 | tokens | a1 | 30 | ok", "M2 | sessions | b2 | 41 | ok", "M3 | cleanup | c3 | 25 | ok"],
154
+ }),
155
+ ];
156
+
157
+ /** A role of the fixtures; `since` minutes before the fixed clock (2026-09-26 12:00 UTC) for a live one. */
158
+ const role = (o: Partial<RoleRow> & Pick<RoleRow, "run" | "dispatchId" | "name" | "rung">): RoleRow => ({
159
+ role: o.name.split("-")[0] ?? "worker",
160
+ status: "ok",
161
+ live: false,
162
+ since: "2026-09-26T11:48:10.000Z",
163
+ secs: null,
164
+ lastEvent: null,
165
+ replyStatus: "complete",
166
+ ...o,
167
+ });
168
+
169
+ const auth = (continued: SessionRun["continued"]): SessionRun => ({
170
+ id: AUTH,
171
+ title: "Auth refactor",
172
+ repo: "/home/me/api",
173
+ createdAt: "2026-09-25T09:00:00.000Z",
174
+ continued,
175
+ continuedIn: continued === "elsewhere" ? "kit follow-up" : null,
176
+ budget: null,
177
+ milestones: [
178
+ { name: "M1", landed: true, what: "tokens" },
179
+ { name: "M2", landed: true, what: "sessions" },
180
+ { name: "M3", landed: true, what: "cleanup" },
181
+ ],
182
+ roles: [
183
+ role({ run: AUTH, dispatchId: "a3", name: "reviewer-M3", rung: "codex:gpt-6-sol#high", secs: 188 }),
184
+ role({ run: AUTH, dispatchId: "a2", name: "worker-M3.L1", rung: "codex:gpt-6-luna#high", secs: 402 }),
185
+ role({
186
+ run: AUTH,
187
+ dispatchId: "a1",
188
+ name: "worker-M2.L1",
189
+ rung: "codex:gpt-6-sol#medium",
190
+ status: "limit",
191
+ secs: 95,
192
+ replyStatus: null,
193
+ }),
194
+ ],
195
+ });
196
+
197
+ /** The Runs tab's sessions: a live one with three live roles, one whose run another session continued, and it. */
198
+ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
199
+ {
200
+ row: {
201
+ key: "s-jobs",
202
+ name: "jobs screen",
203
+ live: true,
204
+ runs: 1,
205
+ liveRoles: 3,
206
+ landed: 1,
207
+ lastActivity: "2026-09-26T11:59:40.000Z",
208
+ },
209
+ runs: [
138
210
  {
139
- lane: "M1.L2",
140
- role: "worker",
141
- source: "lane",
142
- kind: "repo_code",
143
- difficulty: "build",
144
- rung: "codex:gpt-6-luna#high",
211
+ id: JOBS,
212
+ title: "Jobs screen",
213
+ repo: "/home/me/app",
214
+ createdAt: "2026-09-26T11:48:00.000Z",
215
+ continued: null,
216
+ continuedIn: null,
217
+ budget: 0.52,
218
+ milestones: [
219
+ { name: "M0", landed: true, what: "scaffold the jobs screen" },
220
+ { name: "M1", landed: false, what: "" },
221
+ ],
222
+ roles: [
223
+ role({
224
+ run: JOBS,
225
+ dispatchId: "d2",
226
+ name: "worker-M1.L2",
227
+ rung: "codex:gpt-6-sol#medium",
228
+ status: "running",
229
+ live: true,
230
+ since: "2026-09-26T11:55:48.000Z",
231
+ lastEvent: "$ bun test test/jobs --bail",
232
+ replyStatus: null,
233
+ }),
234
+ role({
235
+ run: JOBS,
236
+ dispatchId: "d4",
237
+ name: "worker-M1.L3",
238
+ rung: "codex:gpt-6-luna#high",
239
+ status: "running",
240
+ live: true,
241
+ since: "2026-09-26T11:57:00.000Z",
242
+ lastEvent: "edit src/jobs/list.tsx",
243
+ replyStatus: null,
244
+ }),
245
+ role({
246
+ run: JOBS,
247
+ dispatchId: "d3",
248
+ name: "reviewer-M1",
249
+ rung: "codex:gpt-6-sol#high",
250
+ status: "starting",
251
+ live: true,
252
+ since: "2026-09-26T11:59:56.000Z",
253
+ replyStatus: null,
254
+ }),
255
+ role({
256
+ run: JOBS,
257
+ dispatchId: "d1",
258
+ name: "worker-M1.L1",
259
+ rung: "codex:gpt-6-luna#high",
260
+ secs: 312,
261
+ }),
262
+ ],
145
263
  },
146
264
  ],
147
265
  },
148
266
  {
149
- summary: summary({
150
- id: "20260925-090000-auth-refactor",
151
- title: "Auth refactor",
152
- repo: "/home/me/api",
153
- createdAt: "2026-09-25T09:00:00.000Z",
154
- stateTail: ["Next: done"],
155
- totals: {
156
- runs: 9,
157
- ok: 8,
158
- notOk: ["worker-M2.L1 (limit)"],
159
- tokens: { input: 3_100_000, cached: 2_400_000, output: 160_000 },
160
- costUsd: 1.84,
161
- wallMinutes: 96,
267
+ row: {
268
+ key: "s-kit",
269
+ name: "kit follow-up",
270
+ live: false,
271
+ runs: 1,
272
+ liveRoles: 0,
273
+ landed: 3,
274
+ lastActivity: "2026-09-25T11:20:00.000Z",
275
+ },
276
+ runs: [auth("here")],
277
+ },
278
+ {
279
+ row: {
280
+ key: "s-auth",
281
+ name: "auth build",
282
+ live: false,
283
+ runs: 2,
284
+ liveRoles: 0,
285
+ landed: 5,
286
+ lastActivity: "2026-09-25T10:36:00.000Z",
287
+ },
288
+ runs: [
289
+ auth("elsewhere"),
290
+ {
291
+ id: PLAN4,
292
+ title: "Auth plan 4",
293
+ repo: "/home/me/api",
294
+ createdAt: "2026-09-24T08:00:00.000Z",
295
+ continued: null,
296
+ continuedIn: null,
297
+ budget: null,
298
+ milestones: [
299
+ { name: "M1", landed: true, what: "schema" },
300
+ { name: "M2", landed: true, what: "handlers" },
301
+ ],
302
+ roles: [
303
+ role({
304
+ run: PLAN4,
305
+ dispatchId: "p2",
306
+ name: "reviewer-M2",
307
+ rung: "codex:gpt-6-sol#high",
308
+ secs: 140,
309
+ }),
310
+ role({
311
+ run: PLAN4,
312
+ dispatchId: "p1",
313
+ name: "worker-M2.L1",
314
+ rung: "codex:gpt-6-sol#medium",
315
+ secs: 610,
316
+ }),
317
+ ],
162
318
  },
163
- milestones: [
164
- "M1 | tokens | a1 | 30 | ok",
165
- "M2 | sessions | b2 | 41 | ok",
166
- "M3 | cleanup | c3 | 25 | ok",
167
- ],
168
- }),
169
- climbs: [],
170
- decisions: [],
319
+ ],
171
320
  },
172
321
  ];
173
322
 
174
- const fixtureRow = (d: RunDetail): RunRow => rowOf(d.summary);
323
+ /** The role screen's text: the brief, the reply and the record behind worker-M1.L1 of the jobs screen. */
324
+ const FIXTURE_ROLE: Omit<RoleDetail, "run" | "runTitle" | "dispatchId" | "name" | "role" | "rung" | "state"> =
325
+ {
326
+ brief:
327
+ "Read /home/me/.local/share/catherd/runs/jobs-screen/lanes/M1.L1.md\nFast check: bun test test/jobs/list",
328
+ reply:
329
+ "Done: the list renders every job with its state.\nsrc/jobs/list.tsx:12 — the list\nsrc/jobs/list.test.tsx:4 — its test\nSTATUS: complete — list in place, fast check green",
330
+ record: null,
331
+ };
332
+
333
+ /** The digest `land` writes for M0 of the jobs screen (spec 1.1 §10): what a landed milestone's screen shows. */
334
+ export const FIXTURE_DIGEST = [
335
+ "# M0 — scaffold the jobs screen",
336
+ "",
337
+ "Commit 3f2a9c1 · 9 min · landed 2026-09-26T11:57:00.000Z",
338
+ "A-lines: A1 the jobs screen lists every job with its state",
339
+ "",
340
+ "Lanes:",
341
+ "- M0.L1 · codex:gpt-6-luna#high",
342
+ "- M0.L2 · codex:gpt-6-luna#medium → codex:gpt-6-sol#medium · climbs: fast check red twice",
343
+ "",
344
+ "Reviewer: reviewer-M0 · 1 finding(s): 0 BLOCKER, 0 BUG, 1 NIT",
345
+ "Verifier: PASS (verifier-M0)",
346
+ "Evidence: bun test test/jobs",
347
+ "Tokens: 1204k in (960k cached) · 58k out · Claude subagents 31k (reported)",
348
+ "",
349
+ ].join("\n");
350
+
351
+ /** The record a finished fixture role would have: what the role screen shows under RECORD. */
352
+ function fixtureRecord(r: SessionRun, x: RoleRow): RunRecord {
353
+ const start = Date.parse(x.since);
354
+ return {
355
+ schema: 1,
356
+ runId: r.id,
357
+ dispatchId: x.dispatchId,
358
+ name: x.name,
359
+ role: x.role,
360
+ lane: x.name.includes("-M") && x.name.includes(".") ? x.name.slice(x.name.indexOf("-") + 1) : null,
361
+ backend: x.rung.slice(0, x.rung.indexOf(":")),
362
+ rung: x.rung,
363
+ attempt: 1,
364
+ failoverFrom: null,
365
+ thread: "01a0d0d4-d0a6-71a1-983c-82a9169200b4",
366
+ status: x.status as RunRecord["status"],
367
+ startedAt: x.since,
368
+ endedAt: new Date(start + (x.secs ?? 0) * 1000).toISOString(),
369
+ secs: x.secs ?? 0,
370
+ exitCode: 0,
371
+ signal: null,
372
+ cliVersion: "0.157.0",
373
+ tokens: { input: 812_000, cached: 640_000, output: 41_000 },
374
+ costUsd: null,
375
+ changedOwned: ["src/jobs/list.tsx", "src/jobs/list.test.tsx"],
376
+ violations: [],
377
+ replyStatus: x.replyStatus as RunRecord["replyStatus"],
378
+ replyWhy: x.replyStatus ? "list in place, fast check green" : null,
379
+ threadHeavy: false,
380
+ access: "workspace-write",
381
+ isolated: false,
382
+ images: [],
383
+ error: null,
384
+ replyPath: `roles/${x.name}/${x.dispatchId}/reply.md`,
385
+ };
386
+ }
175
387
 
176
388
  const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
177
389
 
@@ -179,15 +391,27 @@ const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
179
391
  * Effects over the fixtures; profile writes stay in memory and are recorded in `writes`. `repo` is the
180
392
  * directory the TUI stands in and `bindings` the repo bindings, as `liveEffects(repo)` reads them.
181
393
  */
182
- export function fixtureEffects(
183
- o: {
184
- runs?: RunDetail[];
185
- report?: DoctorReport;
186
- repo?: string;
187
- bindings?: Record<string, string>;
188
- } = {},
189
- ): Effects & { writes: string[] } {
394
+ export interface FixtureOptions {
395
+ runs?: RunSummary[];
396
+ sessions?: { row: SessionRow; runs: SessionRun[] }[];
397
+ report?: DoctorReport;
398
+ repo?: string;
399
+ bindings?: Record<string, string>;
400
+ /** false: the run folders cannot be watched here, as on a platform without fs.watch */
401
+ watchable?: boolean;
402
+ }
403
+
404
+ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
405
+ writes: string[];
406
+ /** what a change to a watched run file does: every open watch hears of it */
407
+ touch(): void;
408
+ /** the run folders watched now */
409
+ watched: string[][];
410
+ } {
190
411
  const runs = o.runs ?? FIXTURE_RUNS;
412
+ const sessions = o.sessions ?? FIXTURE_SESSIONS;
413
+ const listeners = new Set<() => void>();
414
+ const watched: string[][] = [];
191
415
  const docs = new Map<string, ProfileDoc>([["default", defaultProfileDoc()]]);
192
416
  let active = "default";
193
417
  const bindings = new Map(Object.entries(o.bindings ?? {}));
@@ -213,13 +437,56 @@ export function fixtureEffects(
213
437
  };
214
438
  return {
215
439
  writes,
440
+ watched,
441
+ touch: () => {
442
+ for (const l of listeners) l();
443
+ },
216
444
  version: "1.0.0",
217
445
  doctor: async () => o.report ?? FIXTURE_REPORT,
218
- runs: () => ({ rows: runs.map(fixtureRow), warnings: [] }),
219
- run(id) {
220
- const d = runs.find((r) => r.summary.id === id);
221
- if (!d) throw new Error(`no run "${id}"`);
222
- return d;
446
+ runs: () => ({ rows: runs.map(rowOf), warnings: [] }),
447
+ sessions: () => ({ rows: sessions.map((x) => x.row), warnings: [] }),
448
+ session(key) {
449
+ const x = sessions.find((y) => y.row.key === key);
450
+ if (!x) throw new Error(`no session "${key}"`);
451
+ return { session: x.row, runs: x.runs, dirs: x.runs.map((r) => `/runs/${r.id}`) };
452
+ },
453
+ role(run, dispatchId) {
454
+ for (const x of sessions)
455
+ for (const r of x.runs) {
456
+ const role = r.id === run ? r.roles.find((y) => y.dispatchId === dispatchId) : undefined;
457
+ if (!role) continue;
458
+ return {
459
+ run,
460
+ runTitle: r.title,
461
+ dispatchId,
462
+ name: role.name,
463
+ role: role.role,
464
+ rung: role.rung,
465
+ state: role.live ? role.status : "finished",
466
+ brief: FIXTURE_ROLE.brief,
467
+ reply: role.live ? "" : FIXTURE_ROLE.reply,
468
+ record: role.live ? null : fixtureRecord(r, role),
469
+ };
470
+ }
471
+ throw new Error(`no dispatch ${dispatchId} in run ${run}`);
472
+ },
473
+ milestone(run, name) {
474
+ for (const x of sessions)
475
+ for (const r of x.runs) {
476
+ const m = r.id === run ? r.milestones.find((y) => y.name === name) : undefined;
477
+ if (!m) continue;
478
+ return { run, runTitle: r.title, ...m, digest: m.landed ? FIXTURE_DIGEST : null };
479
+ }
480
+ throw new Error(`no milestone "${name}" in run ${run}`);
481
+ },
482
+ watch(dirs, onChange) {
483
+ if (o.watchable === false) return null;
484
+ watched.push(dirs);
485
+ listeners.add(onChange);
486
+ return () => {
487
+ listeners.delete(onChange);
488
+ watched.splice(watched.indexOf(dirs), 1);
489
+ };
223
490
  },
224
491
  async cancel(run, name) {
225
492
  writes.push(`cancel ${run} ${name}`);
@@ -113,12 +113,12 @@ export function failoverOptions(
113
113
  if (!(r.enabled || usable(r.rung)) || r.rung.startsWith("claude:")) continue;
114
114
  const q = quotaOf(parseRung(r.rung));
115
115
  if (q === quota) continue;
116
- const inferred = inferredScores(c, rungInfo(c, r.rung)).inferred;
116
+ const via = inferredScores(c, rungInfo(c, r.rung)).via;
117
117
  out.push({
118
118
  value: r.rung,
119
119
  title: shortRung(r.rung),
120
120
  group: m.billing,
121
- detail: inferred ? "inferred" : "",
121
+ detail: via ? `scores borrowed from ${via}` : "",
122
122
  current: r.rung === cur,
123
123
  });
124
124
  }
@@ -350,7 +350,8 @@ export function buildRows(i: TreeInput): Row[] {
350
350
  let mark = "";
351
351
  if (to) {
352
352
  try {
353
- mark = inferredScores(i.catalog, rungInfo(i.catalog, to)).inferred ? " (inferred)" : "";
353
+ const via = inferredScores(i.catalog, rungInfo(i.catalog, to)).via;
354
+ mark = via ? ` (scores borrowed from ${via})` : "";
354
355
  } catch {
355
356
  mark = "";
356
357
  }
@@ -1,7 +1,7 @@
1
1
  import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
3
3
  import type { DoctorReport } from "../../../services/doctor.ts";
4
- import type { Effects, RunRow } from "../effects.ts";
4
+ import type { Effects, RunRow, SessionRow } from "../effects.ts";
5
5
  import { useApp } from "./app.tsx";
6
6
 
7
7
  export interface Polled<T> {
@@ -16,12 +16,14 @@ export interface Polled<T> {
16
16
 
17
17
  /**
18
18
  * Reads `read` now and every `everyMs` while not paused, off the render path (spec §9.4: runs data is
19
- * polled off the render path): a read runs in a timer callback, never while React renders.
19
+ * polled off the render path): a read runs in a timer callback, never while React renders. It reads at once
20
+ * when it mounts, when `key` changes, on `refresh`, when it resumes and when it is turned back on; a new
21
+ * cadence or a pause only reschedules. `off`: no read at all (the data is not shown).
20
22
  */
21
23
  export function usePoll<T>(
22
24
  read: () => T,
23
25
  everyMs: number,
24
- o: { paused?: boolean; key?: string } = {},
26
+ o: { paused?: boolean; key?: string; off?: boolean } = {},
25
27
  ): Polled<T> {
26
28
  const { clock } = useApp();
27
29
  const [state, setState] = useState<{
@@ -33,7 +35,24 @@ export function usePoll<T>(
33
35
  const readRef = useRef(read);
34
36
  readRef.current = read;
35
37
  const [nonce, setNonce] = useState(0);
38
+ // what the last effect run read for: a read at once is due only when this moves on
39
+ const last = useRef<{ clock: unknown; key: string | undefined; nonce: number; paused: boolean } | null>(
40
+ null,
41
+ );
42
+ const paused = o.paused ?? false;
36
43
  useEffect(() => {
44
+ if (o.off) {
45
+ last.current = null;
46
+ return;
47
+ }
48
+ const was = last.current;
49
+ last.current = { clock, key: o.key, nonce, paused };
50
+ const now =
51
+ was === null ||
52
+ was.clock !== clock ||
53
+ was.key !== o.key ||
54
+ was.nonce !== nonce ||
55
+ (was.paused && !paused);
37
56
  const run = () => {
38
57
  try {
39
58
  setState({ value: readRef.current(), error: null, fix: null, at: clock.now() });
@@ -46,13 +65,13 @@ export function usePoll<T>(
46
65
  }));
47
66
  }
48
67
  };
49
- const first = clock.setTimeout(run, 0);
50
- const every = o.paused ? null : clock.setInterval(run, everyMs);
68
+ const first = now ? clock.setTimeout(run, 0) : null;
69
+ const every = paused ? null : clock.setInterval(run, everyMs);
51
70
  return () => {
52
- clock.clearTimeout(first);
71
+ if (first !== null) clock.clearTimeout(first);
53
72
  if (every !== null) clock.clearInterval(every);
54
73
  };
55
- }, [clock, everyMs, o.paused, o.key, nonce]);
74
+ }, [clock, everyMs, paused, o.key, o.off, nonce]);
56
75
  return { ...state, refresh: () => setNonce((n) => n + 1) };
57
76
  }
58
77
 
@@ -71,6 +90,8 @@ export interface Data {
71
90
  checkError: string | null;
72
91
  recheck(): void;
73
92
  runs: Polled<{ rows: RunRow[]; warnings: string[] }>;
93
+ /** spec §4: the Runs tab's sessions, read on the runs' cadence while their list is shown */
94
+ sessions: Polled<{ rows: SessionRow[]; warnings: string[] }>;
74
95
  /** the profile names and the active one; read on the runs' cadence and after every profile write */
75
96
  profiles: Polled<ReturnType<Effects["profiles"]>>;
76
97
  }
@@ -115,6 +136,11 @@ export function DataProvider(props: { children: ReactNode }) {
115
136
  };
116
137
  }, [app.effects, app.clock, nonce]);
117
138
  const runs = usePoll(() => app.effects.runs(), RUNS_EVERY_MS, { paused: app.state.paused });
139
+ // spec §9.4: reading the sessions scans every run, so only while their list is on screen
140
+ const sessions = usePoll(() => app.effects.sessions(), RUNS_EVERY_MS, {
141
+ paused: app.state.paused,
142
+ off: app.state.tab !== "runs" || app.state.session !== null,
143
+ });
118
144
  // cheap (a directory listing and two small reads), and another terminal may change them at any time
119
145
  const profiles = usePoll(() => app.effects.profiles(), RUNS_EVERY_MS);
120
146
  const value = useMemo(
@@ -125,9 +151,10 @@ export function DataProvider(props: { children: ReactNode }) {
125
151
  checkError,
126
152
  recheck: () => setNonce((n) => n + 1),
127
153
  runs,
154
+ sessions,
128
155
  profiles,
129
156
  }),
130
- [report, checking, checkedAt, checkError, runs, profiles],
157
+ [report, checking, checkedAt, checkError, runs, sessions, profiles],
131
158
  );
132
159
  return <DataContext.Provider value={value}>{props.children}</DataContext.Provider>;
133
160
  }
@@ -10,6 +10,16 @@ import { liveEffects } from "./effects.ts";
10
10
  import { fixtureEffects } from "./fixtures.ts";
11
11
  import { createAppKeymap } from "./providers/keymap.tsx";
12
12
  import { Providers } from "./providers/root.tsx";
13
+
14
+ /**
15
+ * OpenTUI's SystemClock counts from process start (performance.now), but the pages compare its time with the
16
+ * wall-clock timestamps catherd writes (admittedAt, createdAt, lastActivity), so it must be epoch milliseconds.
17
+ */
18
+ export class WallClock extends SystemClock {
19
+ override now(): number {
20
+ return Date.now();
21
+ }
22
+ }
13
23
  import { initialState, type Tab } from "./state.ts";
14
24
  import { detectUi, type ThemeMode } from "./theme.ts";
15
25
  import { App } from "./views/app.tsx";
@@ -72,7 +82,7 @@ export async function openTui(o: TuiOptions): Promise<number> {
72
82
  keybinds={keybinds}
73
83
  keymap={createAppKeymap(renderer)}
74
84
  effects={effects}
75
- clock={new SystemClock()}
85
+ clock={new WallClock()}
76
86
  initial={initialState(o.tab ?? "status")}
77
87
  copy={(text) => renderer.copyToClipboardOSC52(text)}
78
88
  onExit={(code, kept) => {