catherd-cli 0.2.1 → 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 (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,573 @@
1
+ import { type MutableRefObject, useEffect, useRef, useState } from "react";
2
+ import { useApp, useBack, useNow } from "../providers/app.tsx";
3
+ import { useData, usePoll } from "../providers/data.tsx";
4
+ import { useCommandLayer } from "../providers/keymap.tsx";
5
+ import { errorToast } from "../providers/toast.tsx";
6
+ import { useUi } from "../providers/theme.tsx";
7
+ import { isArmed } from "../state.ts";
8
+ import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
9
+ import { glyph, mascot, type Token } from "../theme.ts";
10
+ import type { RoleRow, SessionRow, SessionRun } from "../effects.ts";
11
+ import { Line, type Part } from "../widgets/line.tsx";
12
+ import { List, type ListItem, useSelected } from "../widgets/list.tsx";
13
+
14
+ /** How often an open screen is read again when its run folders cannot be watched, unless paused (spec §4). */
15
+ export const RUN_EVERY_MS = 1_000;
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;
18
+
19
+ /**
20
+ * The selected row, where moving the cursor takes back a first ctrl+d (Ruling 3: moving or esc disarms),
21
+ * as the dialog list does.
22
+ */
23
+ function useSelection(
24
+ initial: string | null = null,
25
+ ): [string | null, (key: string) => void, () => string | null] {
26
+ const app = useApp();
27
+ const sel = useSelected(initial);
28
+ const select = (key: string) => {
29
+ if (key !== sel.current() && app.getState().armed) app.dispatch({ type: "disarm" });
30
+ sel.select(key);
31
+ };
32
+ return [sel.selected, select, sel.current];
33
+ }
34
+
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 }) {
71
+ const app = useApp();
72
+ const data = useData();
73
+ const ui = useUi();
74
+ const now = useNow(1_000);
75
+ const [selected, setSelected, selectedNow] = useSelection(props.initial);
76
+ const rows = data.sessions.value?.rows ?? [];
77
+ useCommandLayer("row.runs", {
78
+ "runs.open": () => {
79
+ const k = selectedNow();
80
+ if (k) app.dispatch({ type: "session", key: fromKey(k) });
81
+ },
82
+ });
83
+ const updated = app.state.paused
84
+ ? "paused"
85
+ : data.sessions.at !== null
86
+ ? `updated ${ago(now - data.sessions.at)}`
87
+ : "";
88
+ const items: ListItem[] = rows.map((s) => ({
89
+ key: keyOf(s.key),
90
+ selectable: true,
91
+ render: (sel, w) => (
92
+ <Line width={w} selected={sel} parts={[{ text: " " }, ...sessionParts(s, now, ui.plain)]} />
93
+ ),
94
+ }));
95
+ const warnings = data.sessions.value?.warnings ?? [];
96
+ // a failed read says so, whether rows from an earlier good read are shown, none were, or none exist
97
+ const failed = data.sessions.error;
98
+ const failure = failed ? (
99
+ <Line
100
+ width={props.width}
101
+ parts={[{ text: ` ${glyph("fail", ui.plain)} could not read the runs: ${failed}`, tone: "error" }]}
102
+ />
103
+ ) : null;
104
+ if (rows.length === 0 && data.sessions.value) {
105
+ const art = mascot("waiting");
106
+ return (
107
+ <box flexDirection="column" width={props.width} height={props.height} paddingTop={failed ? 1 : 2}>
108
+ {failure}
109
+ {art.map((l) => (
110
+ <Line key={l} width={props.width} parts={[{ text: ` ${l}`, tone: "muted" }]} />
111
+ ))}
112
+ <Line
113
+ width={props.width}
114
+ parts={[
115
+ { text: " No runs yet. Start one in Claude Code: /catherd <what to build>", tone: "muted" },
116
+ ]}
117
+ />
118
+ </box>
119
+ );
120
+ }
121
+ return (
122
+ <box flexDirection="column" width={props.width} height={props.height}>
123
+ <Line
124
+ width={props.width}
125
+ parts={[
126
+ { text: " SESSIONS", bold: true },
127
+ { text: ` ${updated}`, tone: app.state.paused ? "warning" : "muted" },
128
+ ]}
129
+ />
130
+ {failure}
131
+ <List
132
+ items={items}
133
+ selected={selected}
134
+ onSelect={setSelected}
135
+ width={props.width}
136
+ height={props.height - 1 - (failed ? 1 : 0) - Math.min(2, warnings.length)}
137
+ filter={null}
138
+ empty={failed ? "no runs read yet" : "reading runs…"}
139
+ />
140
+ {warnings.slice(0, 2).map((w) => (
141
+ <Line
142
+ key={w}
143
+ width={props.width}
144
+ parts={[{ text: ` ${glyph("warn", ui.plain)} ${w}`, tone: "warning" }]}
145
+ />
146
+ ))}
147
+ </box>
148
+ );
149
+ }
150
+
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
+ }) {
206
+ const app = useApp();
207
+ const ui = useUi();
208
+ const now = useNow(1_000);
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" }));
213
+ const d = polled.value;
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;
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
+ },
228
+ "runs.cancel": () => {
229
+ const r = roleAt(selectedNow());
230
+ if (!r?.live) return;
231
+ const target = `${r.run}/${r.name}`;
232
+ const t = app.clock.now();
233
+ if (!isArmed(app.getState(), "cancel", target, t))
234
+ return app.dispatch({ type: "arm", what: "cancel", target, at: t });
235
+ app.dispatch({ type: "disarm" });
236
+ void app.effects.cancel(r.run, r.name).then(
237
+ (msg) => {
238
+ app.toast({ variant: "success", message: msg });
239
+ polled.refresh();
240
+ },
241
+ (e: unknown) => app.toast(errorToast(e)),
242
+ );
243
+ },
244
+ });
245
+ if (!d)
246
+ return (
247
+ <Line
248
+ width={props.width}
249
+ parts={[
250
+ {
251
+ text: polled.error ? ` ${polled.error}` : " reading the session…",
252
+ tone: polled.error ? "error" : "muted",
253
+ },
254
+ ]}
255
+ />
256
+ );
257
+ const items: ListItem[] = [];
258
+ const text = (key: string, parts: Part[]) =>
259
+ items.push({ key, selectable: false, render: (_sel, w) => <Line width={w} parts={parts} /> });
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[]) =>
375
+ items.push({
376
+ key: `l${n++}`,
377
+ selectable: true,
378
+ render: (sel, w) => <Line width={w} selected={sel} parts={parts} />,
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" }]);
411
+ }
412
+ return (
413
+ <box flexDirection="column" width={props.width} height={props.height}>
414
+ <Line
415
+ width={props.width}
416
+ parts={[
417
+ { text: ` ${d.name}`, bold: true },
418
+ {
419
+ text: ` ${d.role} · ${shortRung(d.rung)} · ${r?.status ?? d.state} · ${d.runTitle}`,
420
+ tone: "muted",
421
+ },
422
+ {
423
+ text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
424
+ tone: "error",
425
+ },
426
+ ]}
427
+ />
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 (
461
+ <Line
462
+ width={props.width}
463
+ parts={[
464
+ {
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}`,
498
+ tone: "muted",
499
+ },
500
+ {
501
+ text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
502
+ tone: "error",
503
+ },
504
+ ]}
505
+ />
506
+ <List
507
+ items={items}
508
+ selected={selected}
509
+ onSelect={setSelected}
510
+ width={props.width}
511
+ height={props.height - 1}
512
+ filter={null}
513
+ empty=""
514
+ />
515
+ </box>
516
+ );
517
+ }
518
+
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
+ */
524
+ export function RunsView(props: { width: number; height: number }) {
525
+ const app = useApp();
526
+ const data = useData();
527
+ const refresher: Refresher = useRef(null);
528
+ useCommandLayer("tab.runs", {
529
+ "runs.refresh": () => (refresher.current ?? data.sessions.refresh)(),
530
+ "runs.pause": () => {
531
+ app.dispatch({ type: "pause" });
532
+ app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
533
+ },
534
+ });
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} />;
573
+ }