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
@@ -1,47 +1,30 @@
1
+ import { type MutableRefObject, useEffect, useRef, useState } from "react";
1
2
  import { useApp, useBack, useNow } from "../providers/app.tsx";
2
- import { usePoll, useData } from "../providers/data.tsx";
3
+ import { useData, usePoll } from "../providers/data.tsx";
3
4
  import { useCommandLayer } from "../providers/keymap.tsx";
4
5
  import { errorToast } from "../providers/toast.tsx";
5
6
  import { useUi } from "../providers/theme.tsx";
6
7
  import { isArmed } from "../state.ts";
7
- import { ago, clock, shortRung } from "../text.ts";
8
+ import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
8
9
  import { glyph, mascot, type Token } from "../theme.ts";
10
+ import type { RoleRow, SessionRow, SessionRun } from "../effects.ts";
9
11
  import { Line, type Part } from "../widgets/line.tsx";
10
12
  import { List, type ListItem, useSelected } from "../widgets/list.tsx";
11
- import type { RunDetail } from "../effects.ts";
12
- import { runParts } from "./status.tsx";
13
13
 
14
- /** How often an open run is read again, unless paused. */
14
+ /** How often an open screen is read again when its run folders cannot be watched, unless paused (spec §4). */
15
15
  export const RUN_EVERY_MS = 1_000;
16
-
17
- /** `[██████░░░░] 52% · 31/60 min`, green below 80 %, amber below 100 %, red at 100 % (spec §4.6). */
18
- function budgetParts(d: RunDetail, width: number, plain: boolean): Part[] {
19
- const b = d.summary.budget;
20
- if (!b) return [{ text: "no budget cap", tone: "muted" }];
21
- const cells = Math.max(10, Math.min(30, width - 40));
22
- const full = Math.min(cells, Math.round(b.fraction * cells));
23
- const tone: Token = b.fraction >= 1 ? "error" : b.fraction >= 0.8 ? "warning" : "success";
24
- const caps = [
25
- b.minutes ? `${Math.round(b.minutes.spent)}/${b.minutes.cap} min` : "",
26
- b.tokens ? `${b.tokens.spent}/${b.tokens.cap} tokens` : "",
27
- b.usd ? `$${b.usd.spent.toFixed(2)}/$${b.usd.cap.toFixed(2)}` : "",
28
- ].filter(Boolean);
29
- return [
30
- { text: "[" },
31
- { text: glyph("full", plain).repeat(full), tone },
32
- { text: glyph("empty", plain).repeat(cells - full), tone: "muted" },
33
- { text: `] ${Math.round(b.fraction * 100)}%`, tone },
34
- { text: ` · ${caps.join(" · ")}`, tone: "muted" },
35
- ];
36
- }
16
+ /** How often a watched screen is read again all the same: a run that joins the session has no watch yet. */
17
+ export const WATCHED_EVERY_MS = 5_000;
37
18
 
38
19
  /**
39
20
  * The selected row, where moving the cursor takes back a first ctrl+d (Ruling 3: moving or esc disarms),
40
21
  * as the dialog list does.
41
22
  */
42
- function useSelection(): [string | null, (key: string) => void, () => string | null] {
23
+ function useSelection(
24
+ initial: string | null = null,
25
+ ): [string | null, (key: string) => void, () => string | null] {
43
26
  const app = useApp();
44
- const sel = useSelected();
27
+ const sel = useSelected(initial);
45
28
  const select = (key: string) => {
46
29
  if (key !== sel.current() && app.getState().armed) app.dispatch({ type: "disarm" });
47
30
  sel.select(key);
@@ -49,41 +32,76 @@ function useSelection(): [string | null, (key: string) => void, () => string | n
49
32
  return [sel.selected, select, sel.current];
50
33
  }
51
34
 
52
- function RunList(props: { width: number; height: number }) {
35
+ /** One session of the top level: `● live <name> 2 runs · 3 live roles · 1 landed 4m ago` (spec §4). */
36
+ export function sessionParts(s: SessionRow, now: number, plain: boolean): Part[] {
37
+ return [
38
+ s.live
39
+ ? { text: `${glyph("live", plain)} live `, tone: "info" }
40
+ : { text: `${glyph("dot", plain)} idle `, tone: "muted" },
41
+ { text: `${s.name} `, bold: true },
42
+ {
43
+ text: `${plural(s.runs, "run")} · ${plural(s.liveRoles, "live role")} · ${s.landed} landed `,
44
+ tone: "muted",
45
+ },
46
+ { text: ago(now - Date.parse(s.lastActivity)), tone: "muted" },
47
+ ];
48
+ }
49
+
50
+ const keyOf = (k: string | null) => (k === null ? "earlier" : `s:${k}`);
51
+ const fromKey = (k: string) => (k === "earlier" ? null : k.slice("s:".length));
52
+ const roleKey = (run: string, dispatchId: string) => `role:${run}:${dispatchId}`;
53
+ const milestoneKey = (run: string, name: string) => `ms:${run}:${name}`;
54
+
55
+ /** What `r` re-reads: the open screen registers its read here (the command lives on the tab). */
56
+ type Refresher = MutableRefObject<(() => void) | null>;
57
+
58
+ /** Registers `refresh` as the open screen's while it is mounted. */
59
+ function useRefresher(refresher: Refresher, refresh: () => void) {
60
+ // after every commit, and cleared only while still its own: the screen that replaces this one registers
61
+ // after this one's cleanup has run
62
+ useEffect(() => {
63
+ refresher.current = refresh;
64
+ return () => {
65
+ if (refresher.current === refresh) refresher.current = null;
66
+ };
67
+ });
68
+ }
69
+
70
+ function SessionList(props: { width: number; height: number; initial: string | null }) {
53
71
  const app = useApp();
54
72
  const data = useData();
55
73
  const ui = useUi();
56
74
  const now = useNow(1_000);
57
- const [selected, setSelected, selectedNow] = useSelection();
58
- const rows = data.runs.value?.rows ?? [];
75
+ const [selected, setSelected, selectedNow] = useSelection(props.initial);
76
+ const rows = data.sessions.value?.rows ?? [];
59
77
  useCommandLayer("row.runs", {
60
78
  "runs.open": () => {
61
- const id = selectedNow();
62
- if (id) app.dispatch({ type: "run", id });
79
+ const k = selectedNow();
80
+ if (k) app.dispatch({ type: "session", key: fromKey(k) });
63
81
  },
64
82
  });
65
83
  const updated = app.state.paused
66
84
  ? "paused"
67
- : data.runs.at !== null
68
- ? `updated ${ago(now - data.runs.at)}`
85
+ : data.sessions.at !== null
86
+ ? `updated ${ago(now - data.sessions.at)}`
69
87
  : "";
70
- const items: ListItem[] = rows.map((r) => ({
71
- key: r.id,
88
+ const items: ListItem[] = rows.map((s) => ({
89
+ key: keyOf(s.key),
72
90
  selectable: true,
73
91
  render: (sel, w) => (
74
- <Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
92
+ <Line width={w} selected={sel} parts={[{ text: " " }, ...sessionParts(s, now, ui.plain)]} />
75
93
  ),
76
94
  }));
77
- const warnings = data.runs.value?.warnings ?? [];
95
+ const warnings = data.sessions.value?.warnings ?? [];
78
96
  // a failed read says so, whether rows from an earlier good read are shown, none were, or none exist
79
- const failed = data.runs.error;
97
+ const failed = data.sessions.error;
80
98
  const failure = failed ? (
81
99
  <Line
82
100
  width={props.width}
83
101
  parts={[{ text: ` ${glyph("fail", ui.plain)} could not read the runs: ${failed}`, tone: "error" }]}
84
102
  />
85
103
  ) : null;
86
- if (rows.length === 0 && data.runs.value) {
104
+ if (rows.length === 0 && data.sessions.value) {
87
105
  const art = mascot("waiting");
88
106
  return (
89
107
  <box flexDirection="column" width={props.width} height={props.height} paddingTop={failed ? 1 : 2}>
@@ -105,7 +123,7 @@ function RunList(props: { width: number; height: number }) {
105
123
  <Line
106
124
  width={props.width}
107
125
  parts={[
108
- { text: " RUNS", bold: true },
126
+ { text: " SESSIONS", bold: true },
109
127
  { text: ` ${updated}`, tone: app.state.paused ? "warning" : "muted" },
110
128
  ]}
111
129
  />
@@ -130,27 +148,92 @@ function RunList(props: { width: number; height: number }) {
130
148
  );
131
149
  }
132
150
 
133
- function RunView(props: { id: string; width: number; height: number }) {
151
+ /** A finished role's glyph and colour by its record's status; a live one's by running or starting. */
152
+ function roleMark(r: RoleRow, plain: boolean): Part {
153
+ if (r.live) return { text: glyph(r.status === "running" ? "live" : "waiting", plain), tone: "info" };
154
+ const tone: Token =
155
+ r.status === "ok"
156
+ ? "success"
157
+ : r.status === "cancelled"
158
+ ? "muted"
159
+ : r.status === "limit"
160
+ ? "warning"
161
+ : "error";
162
+ return { text: glyph(r.status === "ok" ? "ok" : r.status === "cancelled" ? "skip" : "fail", plain), tone };
163
+ }
164
+
165
+ /** A run's heading on its session's screen: title, repo, when it started, its budget, where it moved. */
166
+ function runHeading(r: SessionRun, now: number, plain: boolean): Part[] {
167
+ const bits = [r.repo, `started ${ago(now - Date.parse(r.createdAt))}`];
168
+ if (r.budget !== null) bits.push(`budget ${Math.round(r.budget * 100)}%`);
169
+ const moved =
170
+ r.continued === "here" ? "continued here" : r.continuedIn ? `continued in ${r.continuedIn}` : null;
171
+ return [
172
+ { text: ` ${glyph("shut", plain)} ` },
173
+ { text: r.title, bold: true },
174
+ { text: ` ${bits.join(" · ")}`, tone: "muted" },
175
+ ...(moved ? [{ text: ` · ${moved}`, tone: "info" as Token }] : []),
176
+ ];
177
+ }
178
+
179
+ /** Spec §4: the open screen redraws when a run file changes, else every second; `p` stops both. */
180
+ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
181
+ const app = useApp();
182
+ const [watching, setWatching] = useState(false);
183
+ const polled = usePoll(read, watching ? WATCHED_EVERY_MS : RUN_EVERY_MS, { paused: app.state.paused, key });
184
+ const refresh = useRef(polled.refresh);
185
+ refresh.current = polled.refresh;
186
+ const dirs = polled.value?.dirs.join("\n") ?? "";
187
+ useEffect(() => {
188
+ if (app.state.paused || dirs === "") {
189
+ setWatching(false);
190
+ return;
191
+ }
192
+ const stop = app.effects.watch(dirs.split("\n"), () => refresh.current());
193
+ setWatching(stop !== null);
194
+ return () => stop?.();
195
+ }, [dirs, app.state.paused, app.effects]);
196
+ return { ...polled, watching };
197
+ }
198
+
199
+ function SessionView(props: {
200
+ sessionKey: string | null;
201
+ width: number;
202
+ height: number;
203
+ initial: string | null;
204
+ refresher: Refresher;
205
+ }) {
134
206
  const app = useApp();
135
207
  const ui = useUi();
136
208
  const now = useNow(1_000);
137
- const [selected, setSelected, selectedNow] = useSelection();
138
- const polled = usePoll(() => app.effects.run(props.id), RUN_EVERY_MS, {
139
- paused: app.state.paused,
140
- key: props.id,
141
- });
142
- useBack(true, "view", () => app.dispatch({ type: "run", id: null }));
209
+ const [selected, setSelected, selectedNow] = useSelection(props.initial);
210
+ const polled = useLiveRead(() => app.effects.session(props.sessionKey), String(props.sessionKey));
211
+ useRefresher(props.refresher, polled.refresh);
212
+ useBack(true, "view", () => app.dispatch({ type: "up" }));
143
213
  const d = polled.value;
144
- const laneOf = (key: string | null) => (key?.startsWith("lane:") ? key.slice("lane:".length) : null);
214
+ const roleAt = (key: string | null) =>
215
+ d?.runs.flatMap((r) => r.roles).find((x) => key === roleKey(x.run, x.dispatchId)) ?? null;
216
+ const milestoneAt = (key: string | null) =>
217
+ d?.runs
218
+ .flatMap((r) => r.milestones.map((m) => ({ run: r.id, name: m.name })))
219
+ .find((x) => key === milestoneKey(x.run, x.name)) ?? null;
145
220
  useCommandLayer("row.runs", {
221
+ "runs.open": () => {
222
+ const k = selectedNow();
223
+ const m = milestoneAt(k);
224
+ if (m) return app.dispatch({ type: "milestone", run: m.run, name: m.name });
225
+ const r = roleAt(k);
226
+ if (r) app.dispatch({ type: "role", run: r.run, dispatchId: r.dispatchId });
227
+ },
146
228
  "runs.cancel": () => {
147
- const lane = laneOf(selectedNow());
148
- if (!lane) return;
229
+ const r = roleAt(selectedNow());
230
+ if (!r?.live) return;
231
+ const target = `${r.run}/${r.name}`;
149
232
  const t = app.clock.now();
150
- if (!isArmed(app.getState(), "cancel", lane, t))
151
- return app.dispatch({ type: "arm", what: "cancel", target: lane, at: t });
233
+ if (!isArmed(app.getState(), "cancel", target, t))
234
+ return app.dispatch({ type: "arm", what: "cancel", target, at: t });
152
235
  app.dispatch({ type: "disarm" });
153
- void app.effects.cancel(props.id, lane).then(
236
+ void app.effects.cancel(r.run, r.name).then(
154
237
  (msg) => {
155
238
  app.toast({ variant: "success", message: msg });
156
239
  polled.refresh();
@@ -165,88 +248,259 @@ function RunView(props: { id: string; width: number; height: number }) {
165
248
  width={props.width}
166
249
  parts={[
167
250
  {
168
- text: polled.error ? ` ${polled.error}` : " reading the run…",
251
+ text: polled.error ? ` ${polled.error}` : " reading the session…",
169
252
  tone: polled.error ? "error" : "muted",
170
253
  },
171
254
  ]}
172
255
  />
173
256
  );
174
- const s = d.summary;
175
257
  const items: ListItem[] = [];
176
258
  const text = (key: string, parts: Part[]) =>
177
259
  items.push({ key, selectable: false, render: (_sel, w) => <Line width={w} parts={parts} /> });
178
- const heading = (key: string, label: string) => text(key, [{ text: ` ${label}`, bold: true }]);
179
- heading("h:live", "LIVE");
180
- if (s.live.length === 0) text("live:none", [{ text: " no role is running", tone: "muted" }]);
181
- for (const l of s.live) {
182
- const armed = isArmed(app.state, "cancel", l.name, app.clock.now());
260
+ for (const r of d.runs) {
261
+ text(`run:${r.id}`, runHeading(r, now, ui.plain));
262
+ // spec 1.1 §10: each milestone is a row; enter opens it on its digest
263
+ for (const m of r.milestones)
264
+ items.push({
265
+ key: milestoneKey(r.id, m.name),
266
+ selectable: true,
267
+ render: (sel, w) => (
268
+ <Line
269
+ width={w}
270
+ selected={sel}
271
+ parts={[
272
+ { text: " " },
273
+ m.landed
274
+ ? { text: `${glyph("ok", ui.plain)} ${m.name}`, tone: "success" }
275
+ : { text: `${glyph("waiting", ui.plain)} ${m.name}`, tone: "muted" },
276
+ m.landed
277
+ ? { text: m.what ? ` ${m.what}` : "", tone: "muted" }
278
+ : { text: " not landed", tone: "muted" },
279
+ ]}
280
+ />
281
+ ),
282
+ });
283
+ if (r.roles.length === 0) text(`none:${r.id}`, [{ text: " no role has run yet", tone: "muted" }]);
284
+ for (const x of r.roles) {
285
+ const secs = x.live ? (now - Date.parse(x.since)) / 1000 : (x.secs ?? 0);
286
+ const armed = isArmed(app.state, "cancel", `${x.run}/${x.name}`, app.clock.now());
287
+ items.push({
288
+ key: roleKey(x.run, x.dispatchId),
289
+ selectable: true,
290
+ render: (sel, w) => (
291
+ <Line
292
+ width={w}
293
+ selected={sel}
294
+ parts={[
295
+ { text: " " },
296
+ roleMark(x, ui.plain),
297
+ { text: ` ${x.name.padEnd(15)} `, bold: true },
298
+ { text: shortRung(x.rung).padEnd(19) },
299
+ { text: x.status.padEnd(9), tone: x.live ? "info" : "muted" },
300
+ { text: clock(secs).padEnd(7) },
301
+ armed
302
+ ? { text: "press ctrl+d again to cancel", tone: "warning" }
303
+ : { text: x.lastEvent ?? "", tone: "muted" },
304
+ ]}
305
+ />
306
+ ),
307
+ });
308
+ }
309
+ }
310
+ const s = d.session;
311
+ const updated = app.state.paused ? "paused" : polled.at !== null ? `updated ${ago(now - polled.at)}` : "";
312
+ return (
313
+ <box flexDirection="column" width={props.width} height={props.height}>
314
+ <Line
315
+ width={props.width}
316
+ parts={[
317
+ { text: ` ${s.name} `, bold: true },
318
+ s.live
319
+ ? { text: `${glyph("live", ui.plain)} live`, tone: "info" }
320
+ : { text: `${glyph("dot", ui.plain)} idle`, tone: "muted" },
321
+ { text: ` · ${plural(s.runs, "run")} · ${plural(s.liveRoles, "live role")} · `, tone: "muted" },
322
+ { text: updated, tone: app.state.paused ? "warning" : "muted" },
323
+ {
324
+ text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
325
+ tone: "error",
326
+ },
327
+ ]}
328
+ />
329
+ <List
330
+ items={items}
331
+ selected={selected}
332
+ onSelect={setSelected}
333
+ width={props.width}
334
+ height={props.height - 1}
335
+ filter={null}
336
+ empty=""
337
+ />
338
+ </box>
339
+ );
340
+ }
341
+
342
+ function RoleView(props: {
343
+ run: string;
344
+ dispatchId: string;
345
+ width: number;
346
+ height: number;
347
+ refresher: Refresher;
348
+ }) {
349
+ const app = useApp();
350
+ const ui = useUi();
351
+ const [selected, setSelected] = useSelection();
352
+ const polled = usePoll(() => app.effects.role(props.run, props.dispatchId), RUN_EVERY_MS, {
353
+ paused: app.state.paused,
354
+ key: `${props.run}/${props.dispatchId}`,
355
+ });
356
+ useRefresher(props.refresher, polled.refresh);
357
+ useBack(true, "view", () => app.dispatch({ type: "up" }));
358
+ const d = polled.value;
359
+ if (!d)
360
+ return (
361
+ <Line
362
+ width={props.width}
363
+ parts={[
364
+ {
365
+ text: polled.error ? ` ${polled.error}` : " reading the role…",
366
+ tone: polled.error ? "error" : "muted",
367
+ },
368
+ ]}
369
+ />
370
+ );
371
+ const items: ListItem[] = [];
372
+ const room = Math.max(10, props.width - 4);
373
+ let n = 0;
374
+ const line = (parts: Part[]) =>
183
375
  items.push({
184
- key: `lane:${l.name}`,
376
+ key: `l${n++}`,
185
377
  selectable: true,
186
- render: (sel, w) => (
187
- <Line
188
- width={w}
189
- selected={sel}
190
- parts={[
191
- { text: ` ${glyph(l.state === "running" ? "live" : "waiting", ui.plain)} `, tone: "info" },
192
- { text: l.name.padEnd(16), bold: true },
193
- { text: shortRung(l.rung).padEnd(20) },
194
- { text: clock(l.secs).padEnd(8) },
195
- armed
196
- ? { text: "press ctrl+d again to cancel", tone: "warning" }
197
- : { text: l.state, tone: "muted" },
198
- ]}
199
- />
200
- ),
378
+ render: (sel, w) => <Line width={w} selected={sel} parts={parts} />,
201
379
  });
380
+ const section = (title: string, body: string, none: string) => {
381
+ line([{ text: ` ${title}`, bold: true }]);
382
+ const lines = body.trimEnd() ? body.trimEnd().split("\n") : [];
383
+ if (lines.length === 0) line([{ text: ` ${none}`, tone: "muted" }]);
384
+ for (const l of lines) for (const w of wrap(l, room)) line([{ text: ` ${w}` }]);
385
+ };
386
+ section("BRIEF", d.brief, "no brief");
387
+ section("REPLY", d.reply, d.state === "finished" ? "no reply" : "no reply yet: the role is running");
388
+ line([{ text: " RECORD", bold: true }]);
389
+ const r = d.record;
390
+ if (!r) line([{ text: " none yet: the role is running", tone: "muted" }]);
391
+ else {
392
+ const k = (x: number) => `${Math.round(x / 1000)}k`;
393
+ line([
394
+ { text: ` ${r.status}`, tone: r.status === "ok" ? "success" : "error" },
395
+ {
396
+ text: r.replyStatus
397
+ ? ` · STATUS ${r.replyStatus}${r.replyWhy ? ` — ${r.replyWhy}` : ""}`
398
+ : " · no STATUS",
399
+ },
400
+ ]);
401
+ line([
402
+ {
403
+ text: ` ${clock(r.secs)} · ${k(r.tokens.input)} in (${k(r.tokens.cached)} cached) · ${k(r.tokens.output)} out`,
404
+ tone: "muted",
405
+ },
406
+ ]);
407
+ line([{ text: ` changed ${r.changedOwned.join(", ") || "nothing it owns"}`, tone: "muted" }]);
408
+ if (r.violations.length)
409
+ line([{ text: ` outside its lane: ${r.violations.join(", ")}`, tone: "warning" }]);
410
+ if (r.thread) line([{ text: ` thread ${r.thread}`, tone: "muted" }]);
202
411
  }
203
- if (d.climbs.length) heading("h:climbs", "CLIMBS");
204
- d.climbs.forEach((c, i) =>
205
- text(`climb:${i}`, [
206
- { text: ` ${c.lane.padEnd(8)} ` },
207
- { text: `${shortRung(c.from)} ${glyph("arrow", ui.plain)} ${shortRung(c.to)}` },
208
- { text: ` ${c.reason}${c.env ? " (environment)" : ""}`, tone: "muted" },
209
- ]),
210
- );
211
- if (d.decisions.length) heading("h:routes", "ROUTES");
212
- d.decisions.forEach((r, i) =>
213
- text(`route:${i}`, [
214
- { text: ` ${r.lane.padEnd(8)} ${r.role.padEnd(10)}` },
215
- { text: `${r.source.padEnd(8)}`, tone: r.source === "jev" ? "info" : "muted" },
216
- { text: `${r.kind ?? "-"}/${r.difficulty ?? "-"} `, tone: "muted" },
217
- { text: shortRung(r.rung) },
218
- ]),
219
- );
220
- if (s.milestones.length) heading("h:landed", "LANDED");
221
- s.milestones.forEach((m, i) =>
222
- text(`landed:${i}`, [{ text: ` ${glyph("ok", ui.plain)} `, tone: "success" }, { text: m }]),
223
- );
224
- if (s.stateTail.length) heading("h:next", "STATE");
225
- s.stateTail.forEach((l, i) => text(`state:${i}`, [{ text: ` ${l}`, tone: "muted" }]));
226
- const updated = app.state.paused ? "paused" : polled.at !== null ? `updated ${ago(now - polled.at)}` : "";
227
- const t = s.totals;
228
412
  return (
229
413
  <box flexDirection="column" width={props.width} height={props.height}>
230
414
  <Line
231
415
  width={props.width}
232
416
  parts={[
233
- { text: ` ${s.title}`, bold: true },
234
- { text: ` ${s.repo} · started ${ago(now - Date.parse(s.createdAt))} · `, tone: "muted" },
235
- { text: updated, tone: app.state.paused ? "warning" : "muted" },
417
+ { text: ` ${d.name}`, bold: true },
418
+ {
419
+ text: ` ${d.role} · ${shortRung(d.rung)} · ${r?.status ?? d.state} · ${d.runTitle}`,
420
+ tone: "muted",
421
+ },
236
422
  {
237
- text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read the run: ${polled.error}` : "",
423
+ text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
238
424
  tone: "error",
239
425
  },
240
426
  ]}
241
427
  />
242
- <Line width={props.width} parts={[{ text: " budget " }, ...budgetParts(d, props.width, ui.plain)]} />
428
+ <List
429
+ items={items}
430
+ selected={selected}
431
+ onSelect={setSelected}
432
+ width={props.width}
433
+ height={props.height - 1}
434
+ filter={null}
435
+ empty=""
436
+ />
437
+ </box>
438
+ );
439
+ }
440
+
441
+ /** Spec 1.1 §10: a milestone's screen, the digest `land` wrote for it. */
442
+ function MilestoneView(props: {
443
+ run: string;
444
+ name: string;
445
+ width: number;
446
+ height: number;
447
+ refresher: Refresher;
448
+ }) {
449
+ const app = useApp();
450
+ const ui = useUi();
451
+ const [selected, setSelected] = useSelection();
452
+ const polled = usePoll(() => app.effects.milestone(props.run, props.name), RUN_EVERY_MS, {
453
+ paused: app.state.paused,
454
+ key: `${props.run}/${props.name}`,
455
+ });
456
+ useRefresher(props.refresher, polled.refresh);
457
+ useBack(true, "view", () => app.dispatch({ type: "up" }));
458
+ const d = polled.value;
459
+ if (!d)
460
+ return (
243
461
  <Line
244
462
  width={props.width}
245
463
  parts={[
246
464
  {
247
- text: ` ${t.runs} role runs, ${t.ok} ok · ${(t.tokens.input / 1000).toFixed(0)}k in · ${(t.tokens.output / 1000).toFixed(0)}k out · $${t.costUsd.toFixed(2)}`,
465
+ text: polled.error ? ` ${polled.error}` : " reading the milestone…",
466
+ tone: polled.error ? "error" : "muted",
467
+ },
468
+ ]}
469
+ />
470
+ );
471
+ const items: ListItem[] = [];
472
+ const room = Math.max(10, props.width - 4);
473
+ let n = 0;
474
+ const line = (parts: Part[]) =>
475
+ items.push({
476
+ key: `l${n++}`,
477
+ selectable: true,
478
+ render: (sel, w) => <Line width={w} selected={sel} parts={parts} />,
479
+ });
480
+ line([{ text: " DIGEST", bold: true }]);
481
+ const body = d.digest?.trimEnd() ?? "";
482
+ if (!body)
483
+ line([
484
+ {
485
+ text: ` no digest yet${d.landed ? "" : ": the milestone has not landed"}`,
486
+ tone: "muted",
487
+ },
488
+ ]);
489
+ else for (const l of body.split("\n")) for (const w of wrapHanging(l, room)) line([{ text: ` ${w}` }]);
490
+ return (
491
+ <box flexDirection="column" width={props.width} height={props.height}>
492
+ <Line
493
+ width={props.width}
494
+ parts={[
495
+ { text: ` ${d.name}`, bold: true },
496
+ {
497
+ text: ` ${d.what ? `${d.what} · ` : ""}${d.landed ? "landed" : "not landed"} · ${d.runTitle}`,
248
498
  tone: "muted",
249
499
  },
500
+ {
501
+ text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
502
+ tone: "error",
503
+ },
250
504
  ]}
251
505
  />
252
506
  <List
@@ -254,7 +508,7 @@ function RunView(props: { id: string; width: number; height: number }) {
254
508
  selected={selected}
255
509
  onSelect={setSelected}
256
510
  width={props.width}
257
- height={props.height - 3}
511
+ height={props.height - 1}
258
512
  filter={null}
259
513
  empty=""
260
514
  />
@@ -262,20 +516,58 @@ function RunView(props: { id: string; width: number; height: number }) {
262
516
  );
263
517
  }
264
518
 
265
- /** Spec §9.1 tab 3: the run list; enter opens a run (live lanes, climbs, routes, budget, landed); p pauses. */
519
+ /**
520
+ * Spec §4 (tab 3): the sessions, newest activity first; enter opens a session (its runs, milestones and roles,
521
+ * live ones first), enter on a milestone opens its digest (spec 1.1 §10), enter on a role opens it (brief,
522
+ * reply, record); esc goes back one level; p pauses.
523
+ */
266
524
  export function RunsView(props: { width: number; height: number }) {
267
525
  const app = useApp();
268
526
  const data = useData();
527
+ const refresher: Refresher = useRef(null);
269
528
  useCommandLayer("tab.runs", {
270
- "runs.refresh": () => data.runs.refresh(),
529
+ "runs.refresh": () => (refresher.current ?? data.sessions.refresh)(),
271
530
  "runs.pause": () => {
272
531
  app.dispatch({ type: "pause" });
273
532
  app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
274
533
  },
275
534
  });
276
- return app.state.run ? (
277
- <RunView id={app.state.run} width={props.width} height={props.height} />
278
- ) : (
279
- <RunList width={props.width} height={props.height} />
280
- );
535
+ const { milestone, role, session } = app.state;
536
+ // esc lands on the row it came from: the last session opened, and the last milestone or role opened in it
537
+ const back = useRef<{ session: string | null; row: string | null }>({ session: null, row: null });
538
+ if (session && back.current.session !== keyOf(session.key))
539
+ back.current = { session: keyOf(session.key), row: null };
540
+ if (milestone) back.current.row = milestoneKey(milestone.run, milestone.name);
541
+ if (role) back.current.row = roleKey(role.run, role.dispatchId);
542
+ if (milestone)
543
+ return (
544
+ <MilestoneView
545
+ run={milestone.run}
546
+ name={milestone.name}
547
+ width={props.width}
548
+ height={props.height}
549
+ refresher={refresher}
550
+ />
551
+ );
552
+ if (role)
553
+ return (
554
+ <RoleView
555
+ run={role.run}
556
+ dispatchId={role.dispatchId}
557
+ width={props.width}
558
+ height={props.height}
559
+ refresher={refresher}
560
+ />
561
+ );
562
+ if (session)
563
+ return (
564
+ <SessionView
565
+ sessionKey={session.key}
566
+ width={props.width}
567
+ height={props.height}
568
+ initial={back.current.row}
569
+ refresher={refresher}
570
+ />
571
+ );
572
+ return <SessionList width={props.width} height={props.height} initial={back.current.session} />;
281
573
  }
@@ -127,7 +127,8 @@ export function StatusView(props: { width: number; height: number }) {
127
127
  parts={[
128
128
  { text: ` ${profiles.here}`, bold: true },
129
129
  {
130
- text: ` ${hereWord(profiles)} · ${plural(profiles.names.length, "profile")}`,
130
+ // "…" until the first read: never "0 profiles" on the first frame (spec 1.1 §13)
131
+ text: ` ${hereWord(profiles)} · ${data.profiles.value ? plural(profiles.names.length, "profile") : "…"}`,
131
132
  tone: "muted",
132
133
  },
133
134
  ]}
@@ -188,8 +189,10 @@ export function StatusView(props: { width: number; height: number }) {
188
189
  if (name !== null) {
189
190
  showProfile(app, name);
190
191
  } else if (selected?.startsWith("run:")) {
192
+ // spec §4: a run opens in its session's screen, which shows it with the session's other runs
193
+ const row = runs.find((r) => `run:${r.id}` === selected);
191
194
  app.dispatch({ type: "tab", tab: "runs" });
192
- app.dispatch({ type: "run", id: selected.slice("run:".length) });
195
+ if (row) app.dispatch({ type: "session", key: row.session });
193
196
  }
194
197
  },
195
198
  });