catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -1,619 +0,0 @@
1
- import { type MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
2
- import { waitingLine } from "../../../services/orchestrator-wait.ts";
3
- import { useApp, useBack, useNow } from "../providers/app.tsx";
4
- import { useData, usePoll } from "../providers/data.tsx";
5
- import { useCommandLayer } from "../providers/keymap.tsx";
6
- import { errorToast } from "../providers/toast.tsx";
7
- import { useUi } from "../providers/theme.tsx";
8
- import { isArmed } from "../state.ts";
9
- import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
10
- import { glyph, mascot, type Token } from "../theme.ts";
11
- import type { RoleRow, SessionDetail, SessionRow, SessionRun } from "../effects.ts";
12
- import { Line, type Part } from "../widgets/line.tsx";
13
- import { List, type ListItem, useSelected } from "../widgets/list.tsx";
14
-
15
- /** How often an open screen is read again when its run folders cannot be watched, unless paused (spec §4). */
16
- export const RUN_EVERY_MS = 1_000;
17
- /** How often a watched screen is read again all the same: a run that joins the session has no watch yet. */
18
- export const WATCHED_EVERY_MS = 5_000;
19
-
20
- /**
21
- * The selected row, where moving the cursor takes back a first ctrl+d (Ruling 3: moving or esc disarms),
22
- * as the dialog list does.
23
- */
24
- function useSelection(
25
- initial: string | null = null,
26
- ): [string | null, (key: string) => void, () => string | null] {
27
- const app = useApp();
28
- const sel = useSelected(initial);
29
- const select = (key: string) => {
30
- if (key !== sel.current() && app.getState().armed) app.dispatch({ type: "disarm" });
31
- sel.select(key);
32
- };
33
- return [sel.selected, select, sel.current];
34
- }
35
-
36
- /** One session of the top level: `● live <name> 2 runs · 3 live roles · 1 landed 4m ago` (spec §4). */
37
- export function sessionParts(s: SessionRow, now: number, plain: boolean): Part[] {
38
- return [
39
- s.live
40
- ? { text: `${glyph("live", plain)} live `, tone: "info" }
41
- : { text: `${glyph("dot", plain)} idle `, tone: "muted" },
42
- { text: `${s.name} `, bold: true },
43
- {
44
- text: `${plural(s.runs, "run")} · ${plural(s.liveRoles, "live role")} · ${s.landed} landed `,
45
- tone: "muted",
46
- },
47
- { text: ago(now - Date.parse(s.lastActivity)), tone: "muted" },
48
- ];
49
- }
50
-
51
- const keyOf = (k: string | null) => (k === null ? "earlier" : `s:${k}`);
52
- const fromKey = (k: string) => (k === "earlier" ? null : k.slice("s:".length));
53
- const roleKey = (run: string, dispatchId: string) => `role:${run}:${dispatchId}`;
54
- const milestoneKey = (run: string, name: string) => `ms:${run}:${name}`;
55
-
56
- /** What `r` re-reads: the open screen registers its read here (the command lives on the tab). */
57
- type Refresher = MutableRefObject<(() => void) | null>;
58
-
59
- /** Registers `refresh` as the open screen's while it is mounted. */
60
- function useRefresher(refresher: Refresher, refresh: () => void) {
61
- // after every commit, and cleared only while still its own: the screen that replaces this one registers
62
- // after this one's cleanup has run
63
- useEffect(() => {
64
- refresher.current = refresh;
65
- return () => {
66
- if (refresher.current === refresh) refresher.current = null;
67
- };
68
- });
69
- }
70
-
71
- function SessionList(props: { width: number; height: number; initial: string | null }) {
72
- const app = useApp();
73
- const data = useData();
74
- const ui = useUi();
75
- const now = useNow(1_000);
76
- const [selected, setSelected, selectedNow] = useSelection(props.initial);
77
- const rows = data.sessions.value?.rows ?? [];
78
- useCommandLayer("row.runs", {
79
- "runs.open": () => {
80
- const k = selectedNow();
81
- if (k) app.dispatch({ type: "session", key: fromKey(k) });
82
- },
83
- });
84
- const updated = app.state.paused
85
- ? "paused"
86
- : data.sessions.at !== null
87
- ? `updated ${ago(now - data.sessions.at)}`
88
- : "";
89
- const items: ListItem[] = rows.map((s) => ({
90
- key: keyOf(s.key),
91
- selectable: true,
92
- render: (sel, w) => (
93
- <Line width={w} selected={sel} parts={[{ text: " " }, ...sessionParts(s, now, ui.plain)]} />
94
- ),
95
- }));
96
- const warnings = data.sessions.value?.warnings ?? [];
97
- // a failed read says so, whether rows from an earlier good read are shown, none were, or none exist
98
- const failed = data.sessions.error;
99
- const failure = failed ? (
100
- <Line
101
- width={props.width}
102
- parts={[{ text: ` ${glyph("fail", ui.plain)} could not read the runs: ${failed}`, tone: "error" }]}
103
- />
104
- ) : null;
105
- if (rows.length === 0 && data.sessions.value) {
106
- const art = mascot("waiting");
107
- return (
108
- <box flexDirection="column" width={props.width} height={props.height} paddingTop={failed ? 1 : 2}>
109
- {failure}
110
- {art.map((l) => (
111
- <Line key={l} width={props.width} parts={[{ text: ` ${l}`, tone: "muted" }]} />
112
- ))}
113
- <Line
114
- width={props.width}
115
- parts={[
116
- { text: " No runs yet. Start one in Claude Code: /catherd <what to build>", tone: "muted" },
117
- ]}
118
- />
119
- </box>
120
- );
121
- }
122
- return (
123
- <box flexDirection="column" width={props.width} height={props.height}>
124
- <Line
125
- width={props.width}
126
- parts={[
127
- { text: " SESSIONS", bold: true },
128
- { text: ` ${updated}`, tone: app.state.paused ? "warning" : "muted" },
129
- ]}
130
- />
131
- {failure}
132
- <List
133
- items={items}
134
- selected={selected}
135
- onSelect={setSelected}
136
- width={props.width}
137
- height={props.height - 1 - (failed ? 1 : 0) - Math.min(2, warnings.length)}
138
- filter={null}
139
- empty={failed ? "no runs read yet" : "reading runs…"}
140
- />
141
- {warnings.slice(0, 2).map((w) => (
142
- <Line
143
- key={w}
144
- width={props.width}
145
- parts={[{ text: ` ${glyph("warn", ui.plain)} ${w}`, tone: "warning" }]}
146
- />
147
- ))}
148
- </box>
149
- );
150
- }
151
-
152
- /** A finished role's glyph and colour by its record's status; a live one's by running or starting. */
153
- function roleMark(r: RoleRow, plain: boolean): Part {
154
- if (r.live) return { text: glyph(r.status === "running" ? "live" : "waiting", plain), tone: "info" };
155
- const tone: Token =
156
- r.status === "ok"
157
- ? "success"
158
- : r.status === "cancelled"
159
- ? "muted"
160
- : r.status === "limit"
161
- ? "warning"
162
- : "error";
163
- return { text: glyph(r.status === "ok" ? "ok" : r.status === "cancelled" ? "skip" : "fail", plain), tone };
164
- }
165
-
166
- /** A run's heading on its session's screen: title, repo, when it started, its budget, where it moved. */
167
- function runHeading(r: SessionRun, now: number, plain: boolean): Part[] {
168
- const bits = [r.repo, `started ${ago(now - Date.parse(r.createdAt))}`];
169
- if (r.budget !== null) bits.push(`budget ${Math.round(r.budget * 100)}%`);
170
- const waiting = waitingLine(r.waiting, now);
171
- if (waiting) bits.push(waiting);
172
- const moved =
173
- r.continued === "here" ? "continued here" : r.continuedIn ? `continued in ${r.continuedIn}` : null;
174
- return [
175
- { text: ` ${glyph("shut", plain)} ` },
176
- { text: r.title, bold: true },
177
- { text: ` ${bits.join(" · ")}`, tone: "muted" },
178
- ...(moved ? [{ text: ` · ${moved}`, tone: "info" as Token }] : []),
179
- ];
180
- }
181
-
182
- /** Spec §4: the open screen redraws when a run file changes, else every second; `p` stops both. */
183
- function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
184
- const app = useApp();
185
- const [watching, setWatching] = useState(false);
186
- const polled = usePoll(read, watching ? WATCHED_EVERY_MS : RUN_EVERY_MS, { paused: app.state.paused, key });
187
- // the watch calls the latest refresh; the ref moves after each commit, never during a render
188
- const refresh = useRef(polled.refresh);
189
- useEffect(() => {
190
- refresh.current = polled.refresh;
191
- });
192
- const dirs = polled.value?.dirs.join("\n") ?? "";
193
- useEffect(() => {
194
- if (app.state.paused || dirs === "") {
195
- setWatching(false);
196
- return;
197
- }
198
- const stop = app.effects.watch(dirs.split("\n"), () => refresh.current());
199
- setWatching(stop !== null);
200
- return () => stop?.();
201
- }, [dirs, app.state.paused, app.effects]);
202
- return { ...polled, watching };
203
- }
204
-
205
- /**
206
- * The row a session's screen starts on before the cursor moves: the first row of the run it was opened for,
207
- * else its first live role (live roles can sit below many landed milestones), else its first row.
208
- */
209
- function startRow(d: SessionDetail, run: string | undefined): string | null {
210
- const rows = (r: SessionDetail["runs"][number]) => [
211
- ...r.milestones.map((m) => milestoneKey(r.id, m.name)),
212
- ...r.roles.map((x) => roleKey(x.run, x.dispatchId)),
213
- ];
214
- const asked = run === undefined ? undefined : d.runs.find((r) => r.id === run);
215
- const first = asked ? rows(asked)[0] : undefined;
216
- if (first) return first;
217
- const live = d.runs.flatMap((r) => r.roles).find((x) => x.live);
218
- if (live) return roleKey(live.run, live.dispatchId);
219
- return d.runs.flatMap(rows)[0] ?? null;
220
- }
221
-
222
- function SessionView(props: {
223
- sessionKey: string | null;
224
- run: string | undefined;
225
- width: number;
226
- height: number;
227
- initial: string | null;
228
- refresher: Refresher;
229
- }) {
230
- const app = useApp();
231
- const ui = useUi();
232
- const now = useNow(1_000);
233
- const [chosen, setSelected, chosenNow] = useSelection(props.initial);
234
- const polled = useLiveRead(() => app.effects.session(props.sessionKey), String(props.sessionKey));
235
- useRefresher(props.refresher, polled.refresh);
236
- useBack(true, "view", () => app.dispatch({ type: "up" }));
237
- const d = polled.value;
238
- // each row's role or milestone by its key, built once per read rather than on every key
239
- const byKey = useMemo(() => {
240
- const roles = new Map<string, RoleRow>();
241
- const milestones = new Map<string, { run: string; name: string }>();
242
- for (const r of d?.runs ?? []) {
243
- for (const m of r.milestones) milestones.set(milestoneKey(r.id, m.name), { run: r.id, name: m.name });
244
- for (const x of r.roles) roles.set(roleKey(x.run, x.dispatchId), x);
245
- }
246
- return { roles, milestones, start: d ? startRow(d, props.run) : null };
247
- }, [d, props.run]);
248
- // until the cursor moves, it stands on the start row
249
- const selected = chosen ?? byKey.start;
250
- const selectedNow = () => chosenNow() ?? byKey.start;
251
- const roleAt = (key: string | null) => (key === null ? null : (byKey.roles.get(key) ?? null));
252
- const milestoneAt = (key: string | null) => (key === null ? null : (byKey.milestones.get(key) ?? null));
253
- useCommandLayer("row.runs", {
254
- "runs.open": () => {
255
- const k = selectedNow();
256
- const m = milestoneAt(k);
257
- if (m) return app.dispatch({ type: "milestone", run: m.run, name: m.name });
258
- const r = roleAt(k);
259
- if (r) app.dispatch({ type: "role", run: r.run, dispatchId: r.dispatchId });
260
- },
261
- "runs.cancel": () => {
262
- const r = roleAt(selectedNow());
263
- if (!r?.live) return;
264
- const target = `${r.run}/${r.name}`;
265
- const t = app.clock.now();
266
- if (!isArmed(app.getState(), "cancel", target, t))
267
- return app.dispatch({ type: "arm", what: "cancel", target, at: t });
268
- app.dispatch({ type: "disarm" });
269
- void app.effects.cancel(r.run, r.name).then(
270
- (msg) => {
271
- app.toast({ variant: "success", message: msg });
272
- polled.refresh();
273
- },
274
- (e: unknown) => app.toast(errorToast(e)),
275
- );
276
- },
277
- });
278
- if (!d)
279
- return (
280
- <Line
281
- width={props.width}
282
- parts={[
283
- {
284
- text: polled.error ? ` ${polled.error}` : " reading the session…",
285
- tone: polled.error ? "error" : "muted",
286
- },
287
- ]}
288
- />
289
- );
290
- const items: ListItem[] = [];
291
- const text = (key: string, parts: Part[]) =>
292
- items.push({ key, selectable: false, render: (_sel, w) => <Line width={w} parts={parts} /> });
293
- for (const r of d.runs) {
294
- text(`run:${r.id}`, runHeading(r, now, ui.plain));
295
- // spec 1.1 §10: each milestone is a row; enter opens it on its digest
296
- for (const m of r.milestones)
297
- items.push({
298
- key: milestoneKey(r.id, m.name),
299
- selectable: true,
300
- render: (sel, w) => (
301
- <Line
302
- width={w}
303
- selected={sel}
304
- parts={[
305
- { text: " " },
306
- m.landed
307
- ? { text: `${glyph("ok", ui.plain)} ${m.name}`, tone: "success" }
308
- : { text: `${glyph("waiting", ui.plain)} ${m.name}`, tone: "muted" },
309
- m.landed
310
- ? { text: m.what ? ` ${m.what}` : "", tone: "muted" }
311
- : { text: ` not landed${m.what ? ` · ${m.what}` : ""}`, tone: "muted" },
312
- ]}
313
- />
314
- ),
315
- });
316
- if (r.roles.length === 0) text(`none:${r.id}`, [{ text: " no role has run yet", tone: "muted" }]);
317
- for (const x of r.roles) {
318
- const secs = x.live ? (now - Date.parse(x.since)) / 1000 : (x.secs ?? 0);
319
- const armed = isArmed(app.state, "cancel", `${x.run}/${x.name}`, app.clock.now());
320
- items.push({
321
- key: roleKey(x.run, x.dispatchId),
322
- selectable: true,
323
- render: (sel, w) => (
324
- <Line
325
- width={w}
326
- selected={sel}
327
- parts={[
328
- { text: " " },
329
- roleMark(x, ui.plain),
330
- { text: ` ${x.name.padEnd(15)} `, bold: true },
331
- { text: shortRung(x.rung).padEnd(19) },
332
- { text: x.status.padEnd(9), tone: x.live ? "info" : "muted" },
333
- { text: clock(secs).padEnd(7) },
334
- armed
335
- ? { text: "press ctrl+d again to cancel", tone: "warning" }
336
- : { text: x.lastEvent ?? "", tone: "muted" },
337
- ]}
338
- />
339
- ),
340
- });
341
- }
342
- }
343
- const s = d.session;
344
- const updated = app.state.paused ? "paused" : polled.at !== null ? `updated ${ago(now - polled.at)}` : "";
345
- return (
346
- <box flexDirection="column" width={props.width} height={props.height}>
347
- <Line
348
- width={props.width}
349
- parts={[
350
- { text: ` ${s.name} `, bold: true },
351
- s.live
352
- ? { text: `${glyph("live", ui.plain)} live`, tone: "info" }
353
- : { text: `${glyph("dot", ui.plain)} idle`, tone: "muted" },
354
- { text: ` · ${plural(s.runs, "run")} · ${plural(s.liveRoles, "live role")} · `, tone: "muted" },
355
- { text: updated, tone: app.state.paused ? "warning" : "muted" },
356
- {
357
- text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
358
- tone: "error",
359
- },
360
- ]}
361
- />
362
- <List
363
- items={items}
364
- selected={selected}
365
- onSelect={setSelected}
366
- width={props.width}
367
- height={props.height - 1}
368
- filter={null}
369
- empty=""
370
- />
371
- </box>
372
- );
373
- }
374
-
375
- function RoleView(props: {
376
- run: string;
377
- dispatchId: string;
378
- width: number;
379
- height: number;
380
- refresher: Refresher;
381
- }) {
382
- const app = useApp();
383
- const ui = useUi();
384
- const [selected, setSelected] = useSelection();
385
- // a role with its record is finished for good: nothing on its screen changes, so it is read no more
386
- // (r still reads it again)
387
- const [done, setDone] = useState(false);
388
- const polled = usePoll(() => app.effects.role(props.run, props.dispatchId), RUN_EVERY_MS, {
389
- paused: app.state.paused || done,
390
- key: `${props.run}/${props.dispatchId}`,
391
- });
392
- const recorded = polled.value?.record != null;
393
- useEffect(() => {
394
- if (recorded) setDone(true);
395
- }, [recorded]);
396
- useRefresher(props.refresher, polled.refresh);
397
- useBack(true, "view", () => app.dispatch({ type: "up" }));
398
- const d = polled.value;
399
- if (!d)
400
- return (
401
- <Line
402
- width={props.width}
403
- parts={[
404
- {
405
- text: polled.error ? ` ${polled.error}` : " reading the role…",
406
- tone: polled.error ? "error" : "muted",
407
- },
408
- ]}
409
- />
410
- );
411
- const items: ListItem[] = [];
412
- const room = Math.max(10, props.width - 4);
413
- let n = 0;
414
- const line = (parts: Part[]) =>
415
- items.push({
416
- key: `l${n++}`,
417
- selectable: true,
418
- render: (sel, w) => <Line width={w} selected={sel} parts={parts} />,
419
- });
420
- const section = (title: string, body: string, none: string) => {
421
- line([{ text: ` ${title}`, bold: true }]);
422
- const lines = body.trimEnd() ? body.trimEnd().split("\n") : [];
423
- if (lines.length === 0) line([{ text: ` ${none}`, tone: "muted" }]);
424
- for (const l of lines) for (const w of wrap(l, room)) line([{ text: ` ${w}` }]);
425
- };
426
- section("BRIEF", d.brief, "no brief");
427
- section("REPLY", d.reply, d.state === "finished" ? "no reply" : "no reply yet: the role is running");
428
- line([{ text: " RECORD", bold: true }]);
429
- const r = d.record;
430
- if (!r) line([{ text: " none yet: the role is running", tone: "muted" }]);
431
- else {
432
- const k = (x: number) => `${Math.round(x / 1000)}k`;
433
- line([
434
- { text: ` ${r.status}`, tone: r.status === "ok" ? "success" : "error" },
435
- {
436
- text: r.replyStatus
437
- ? ` · STATUS ${r.replyStatus}${r.replyWhy ? ` — ${r.replyWhy}` : ""}`
438
- : " · no STATUS",
439
- },
440
- ]);
441
- line([
442
- {
443
- text: ` ${clock(r.secs)} · ${k(r.tokens.input)} in (${k(r.tokens.cached)} cached) · ${k(r.tokens.output)} out`,
444
- tone: "muted",
445
- },
446
- ]);
447
- line([{ text: ` changed ${r.changedOwned.join(", ") || "nothing it owns"}`, tone: "muted" }]);
448
- if (r.violations.length)
449
- line([{ text: ` outside its lane: ${r.violations.join(", ")}`, tone: "warning" }]);
450
- if (r.thread) line([{ text: ` thread ${r.thread}`, tone: "muted" }]);
451
- }
452
- return (
453
- <box flexDirection="column" width={props.width} height={props.height}>
454
- <Line
455
- width={props.width}
456
- parts={[
457
- { text: ` ${d.name}`, bold: true },
458
- {
459
- text: ` ${d.role} · ${shortRung(d.rung)} · ${r?.status ?? d.state} · ${d.runTitle}`,
460
- tone: "muted",
461
- },
462
- {
463
- text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
464
- tone: "error",
465
- },
466
- ]}
467
- />
468
- <List
469
- items={items}
470
- selected={selected}
471
- onSelect={setSelected}
472
- width={props.width}
473
- height={props.height - 1}
474
- filter={null}
475
- empty=""
476
- />
477
- </box>
478
- );
479
- }
480
-
481
- /** Spec 1.1 §10: a milestone's screen, the digest `land` wrote for it. */
482
- function MilestoneView(props: {
483
- run: string;
484
- name: string;
485
- width: number;
486
- height: number;
487
- refresher: Refresher;
488
- }) {
489
- const app = useApp();
490
- const ui = useUi();
491
- const [selected, setSelected] = useSelection();
492
- const polled = usePoll(() => app.effects.milestone(props.run, props.name), RUN_EVERY_MS, {
493
- paused: app.state.paused,
494
- key: `${props.run}/${props.name}`,
495
- });
496
- useRefresher(props.refresher, polled.refresh);
497
- useBack(true, "view", () => app.dispatch({ type: "up" }));
498
- const d = polled.value;
499
- if (!d)
500
- return (
501
- <Line
502
- width={props.width}
503
- parts={[
504
- {
505
- text: polled.error ? ` ${polled.error}` : " reading the milestone…",
506
- tone: polled.error ? "error" : "muted",
507
- },
508
- ]}
509
- />
510
- );
511
- const items: ListItem[] = [];
512
- const room = Math.max(10, props.width - 4);
513
- let n = 0;
514
- const line = (parts: Part[]) =>
515
- items.push({
516
- key: `l${n++}`,
517
- selectable: true,
518
- render: (sel, w) => <Line width={w} selected={sel} parts={parts} />,
519
- });
520
- line([{ text: " DIGEST", bold: true }]);
521
- const body = d.digest?.trimEnd() ?? "";
522
- if (!body)
523
- line([
524
- {
525
- text: ` no digest yet${d.landed ? "" : ": the milestone has not landed"}`,
526
- tone: "muted",
527
- },
528
- ]);
529
- else for (const l of body.split("\n")) for (const w of wrapHanging(l, room)) line([{ text: ` ${w}` }]);
530
- return (
531
- <box flexDirection="column" width={props.width} height={props.height}>
532
- <Line
533
- width={props.width}
534
- parts={[
535
- { text: ` ${d.name}`, bold: true },
536
- {
537
- text: ` ${d.what ? `${d.what} · ` : ""}${d.landed ? "landed" : "not landed"} · ${d.runTitle}`,
538
- tone: "muted",
539
- },
540
- {
541
- text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read it: ${polled.error}` : "",
542
- tone: "error",
543
- },
544
- ]}
545
- />
546
- <List
547
- items={items}
548
- selected={selected}
549
- onSelect={setSelected}
550
- width={props.width}
551
- height={props.height - 1}
552
- filter={null}
553
- empty=""
554
- />
555
- </box>
556
- );
557
- }
558
-
559
- /**
560
- * Spec §4 (tab 3): the sessions, newest activity first; enter opens a session (its runs, milestones and roles,
561
- * live ones first), enter on a milestone opens its digest (spec 1.1 §10), enter on a role opens it (brief,
562
- * reply, record); esc goes back one level; p pauses.
563
- */
564
- export function RunsView(props: { width: number; height: number }) {
565
- const app = useApp();
566
- const data = useData();
567
- const refresher: Refresher = useRef(null);
568
- useCommandLayer("tab.runs", {
569
- "runs.refresh": () => (refresher.current ?? data.sessions.refresh)(),
570
- "runs.pause": () => {
571
- app.dispatch({ type: "pause" });
572
- app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
573
- },
574
- });
575
- const { milestone, role, session, back } = app.state;
576
- // esc lands on the row it came from: the last session opened, and the last milestone or role opened in it
577
- const backRow =
578
- back && session && back.session === session.key
579
- ? back.milestone
580
- ? milestoneKey(back.milestone.run, back.milestone.name)
581
- : back.role
582
- ? roleKey(back.role.run, back.role.dispatchId)
583
- : null
584
- : null;
585
- if (milestone)
586
- return (
587
- <MilestoneView
588
- run={milestone.run}
589
- name={milestone.name}
590
- width={props.width}
591
- height={props.height}
592
- refresher={refresher}
593
- />
594
- );
595
- if (role)
596
- return (
597
- <RoleView
598
- run={role.run}
599
- dispatchId={role.dispatchId}
600
- width={props.width}
601
- height={props.height}
602
- refresher={refresher}
603
- />
604
- );
605
- if (session)
606
- return (
607
- <SessionView
608
- sessionKey={session.key}
609
- run={session.run}
610
- width={props.width}
611
- height={props.height}
612
- initial={backRow}
613
- refresher={refresher}
614
- />
615
- );
616
- return (
617
- <SessionList width={props.width} height={props.height} initial={back ? keyOf(back.session) : null} />
618
- );
619
- }