catherd-cli 1.0.0 → 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.
- package/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +63 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
62
|
-
if (
|
|
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.
|
|
68
|
-
? `updated ${ago(now - data.
|
|
85
|
+
: data.sessions.at !== null
|
|
86
|
+
? `updated ${ago(now - data.sessions.at)}`
|
|
69
87
|
: "";
|
|
70
|
-
const items: ListItem[] = rows.map((
|
|
71
|
-
key:
|
|
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: " " }, ...
|
|
92
|
+
<Line width={w} selected={sel} parts={[{ text: " " }, ...sessionParts(s, now, ui.plain)]} />
|
|
75
93
|
),
|
|
76
94
|
}));
|
|
77
|
-
const warnings = data.
|
|
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.
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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 =
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
148
|
-
if (!
|
|
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",
|
|
151
|
-
return app.dispatch({ type: "arm", what: "cancel", target
|
|
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(
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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: `
|
|
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: ` ${
|
|
234
|
-
{
|
|
235
|
-
|
|
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
|
|
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
|
-
<
|
|
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:
|
|
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 -
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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: "
|
|
195
|
+
if (row) app.dispatch({ type: "session", key: row.session });
|
|
193
196
|
}
|
|
194
197
|
},
|
|
195
198
|
});
|