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.
- package/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +118 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- 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 {
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
}
|
package/src/entry/tui/run.tsx
CHANGED
|
@@ -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
|
|
85
|
+
clock={new WallClock()}
|
|
76
86
|
initial={initialState(o.tab ?? "status")}
|
|
77
87
|
copy={(text) => renderer.copyToClipboardOSC52(text)}
|
|
78
88
|
onExit={(code, kept) => {
|