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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ID_PATTERN } from "../../domain/ids.ts";
|
|
4
|
+
import { gateCheck, gatePass } from "../../services/gate-service.ts";
|
|
5
|
+
import { answer, park } from "../../services/questions.ts";
|
|
6
|
+
import type { Deps } from "../../services/ports.ts";
|
|
7
|
+
import { handle } from "./result.ts";
|
|
8
|
+
|
|
9
|
+
// Spec 1.1 §7 and §8: the tools the enforced protocol adds.
|
|
10
|
+
export function registerProtocolTools(server: McpServer, deps: Deps): void {
|
|
11
|
+
const gate = {
|
|
12
|
+
run: z.string(),
|
|
13
|
+
item: z.string().min(1),
|
|
14
|
+
command: z.string().min(1),
|
|
15
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
server.registerTool(
|
|
19
|
+
"gate_check",
|
|
20
|
+
{
|
|
21
|
+
description:
|
|
22
|
+
"The verifier, before running a gate item: { carried: true, passedAt, commit } when this repo already has a pass of the same command on the same content of paths (repo-relative; . for the whole repo: HEAD plus uncommitted not-ignored changes; a git-ignored input such as .env or a build output is hashed only when named as a path), so it reports the item as carried over from that commit instead of running it; else { carried: false }, and it runs the item. Either way the call is recorded as the verifier's current step, which status and peek show. Pass milestone (the M the verifier checks) so the milestone's digest lists only its own carried items.",
|
|
23
|
+
inputSchema: { ...gate, milestone: z.string().regex(ID_PATTERN).optional() },
|
|
24
|
+
},
|
|
25
|
+
(a) => handle(() => gateCheck(deps, a)),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
server.registerTool(
|
|
29
|
+
"gate_pass",
|
|
30
|
+
{
|
|
31
|
+
description:
|
|
32
|
+
"The verifier, after a gate item passed: records the command, the content hash of paths and the commit, with the evidence (a log path or the decisive line), so a later gate_check on unchanged content carries it over.",
|
|
33
|
+
inputSchema: { ...gate, evidence: z.string().min(1) },
|
|
34
|
+
},
|
|
35
|
+
(a) => handle(() => gatePass(deps, a)),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
server.registerTool(
|
|
39
|
+
"park",
|
|
40
|
+
{
|
|
41
|
+
description:
|
|
42
|
+
"An owner question only the user can answer: parks the milestone (land refuses it until answered) and puts it in front of state.md's next step, instead of stopping the run. Returns the parked milestones and hints: push the full question to the user with PushNotification, and go on with the milestones and runs that do not depend on it.",
|
|
43
|
+
inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), question: z.string().min(1) },
|
|
44
|
+
},
|
|
45
|
+
(a) => handle(() => park(deps, a)),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
server.registerTool(
|
|
49
|
+
"answer",
|
|
50
|
+
{
|
|
51
|
+
description:
|
|
52
|
+
"The owner answered a parked milestone's question in this session: records the answer and unparks the milestone. status, peek and run_start list the questions still open.",
|
|
53
|
+
inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), answer: z.string().min(1) },
|
|
54
|
+
},
|
|
55
|
+
(a) => handle(() => answer(deps, a)),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -22,7 +22,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
|
|
|
22
22
|
"run_start",
|
|
23
23
|
{
|
|
24
24
|
description:
|
|
25
|
-
"Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id
|
|
25
|
+
"Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id, the folder, protocol (the next step of the milestone loop and its six-line checklist), and hints when state.md could not be written yet.",
|
|
26
26
|
inputSchema: {
|
|
27
27
|
repo: z.string().min(1),
|
|
28
28
|
title: z.string().min(1),
|
|
@@ -68,7 +68,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
|
|
|
68
68
|
server.registerTool(
|
|
69
69
|
"result",
|
|
70
70
|
{
|
|
71
|
-
description:
|
|
71
|
+
description:
|
|
72
|
+
"A role's latest dispatch: its state, its record once finished, its capped reply, and hints (climb, violation, failed, limit and failover lines). Reading a finished record marks it read: catherd's messages announce records, and this is how you read one.",
|
|
72
73
|
inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
|
|
73
74
|
},
|
|
74
75
|
(a) => handle(() => result(deps, a)),
|
|
@@ -88,7 +89,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
|
|
|
88
89
|
"record_agent_run",
|
|
89
90
|
{
|
|
90
91
|
description:
|
|
91
|
-
"After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings.",
|
|
92
|
+
"After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings. status says how the subagent's work ended: for a verifier it is the verdict, so pass status: \"failed\" when its verdict is FAIL; only a PASS is recorded ok.",
|
|
92
93
|
inputSchema: {
|
|
93
94
|
run: z.string(),
|
|
94
95
|
name: z.string().regex(ID_PATTERN),
|
package/src/entry/mcp/server.ts
CHANGED
|
@@ -3,11 +3,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { errorMessage } from "../../domain/errors.ts";
|
|
5
5
|
import { log } from "../../infra/log.ts";
|
|
6
|
+
import { startNotifier } from "../../services/notifier.ts";
|
|
6
7
|
import type { Deps } from "../../services/ports.ts";
|
|
7
8
|
import { reconcileAll } from "../../services/reconcile.ts";
|
|
8
9
|
import { defaultDeps } from "../deps.ts";
|
|
9
10
|
import { registerDispatchTools } from "./dispatch-tools.ts";
|
|
10
11
|
import { registerLaneTools } from "./lane-tools.ts";
|
|
12
|
+
import { registerProtocolTools } from "./protocol-tools.ts";
|
|
11
13
|
import { sdkToolError, toolOf } from "./result.ts";
|
|
12
14
|
import { registerRunTools } from "./run-tools.ts";
|
|
13
15
|
import { registerSetupTools } from "./setup-tools.ts";
|
|
@@ -52,12 +54,24 @@ export function buildServer(deps: Deps = defaultDeps()): McpServer {
|
|
|
52
54
|
registerLaneTools(server, deps);
|
|
53
55
|
registerDispatchTools(server, deps);
|
|
54
56
|
registerSetupTools(server, deps);
|
|
57
|
+
registerProtocolTools(server, deps);
|
|
55
58
|
return server;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
|
|
63
|
+
* starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done.
|
|
64
|
+
*/
|
|
59
65
|
export async function startMcpServer(): Promise<void> {
|
|
60
66
|
const deps = defaultDeps();
|
|
67
|
+
// which of the session's variables this server got (never their values): the live check of spec §3.9
|
|
68
|
+
log("info", "session", {
|
|
69
|
+
sessionId: Boolean(deps.session?.sessionId),
|
|
70
|
+
hostSessionId: Boolean(deps.session?.hostSessionId),
|
|
71
|
+
socket: Boolean(deps.session?.socketPath),
|
|
72
|
+
token: Boolean(deps.session?.token),
|
|
73
|
+
});
|
|
74
|
+
const notifier = startNotifier(deps);
|
|
61
75
|
await buildServer(deps).connect(new StdioServerTransport());
|
|
62
76
|
try {
|
|
63
77
|
const r = await reconcileAll(deps);
|
|
@@ -69,4 +83,6 @@ export async function startMcpServer(): Promise<void> {
|
|
|
69
83
|
} catch (e) {
|
|
70
84
|
console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
|
|
71
85
|
}
|
|
86
|
+
// whatever finished unread while no server ran, or finished under another server (spec §3.4)
|
|
87
|
+
void notifier.scan();
|
|
72
88
|
}
|
|
@@ -73,7 +73,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
|
|
|
73
73
|
"profile_set",
|
|
74
74
|
{
|
|
75
75
|
description:
|
|
76
|
-
"Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
|
|
76
|
+
"Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
|
|
77
77
|
inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
|
|
78
78
|
},
|
|
79
79
|
(a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
|
|
@@ -40,7 +40,7 @@ export interface StandIn {
|
|
|
40
40
|
via: string | null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** Spec §7.2: each stand-in
|
|
43
|
+
/** Spec §7.2: each stand-in; `via` names the rung whose scores it borrows (spec 1.1 §11). */
|
|
44
44
|
export function standIns(p: Profile, c: Catalog): StandIn[] {
|
|
45
45
|
return Object.entries(p.failover).map(([from, to]) => {
|
|
46
46
|
try {
|
|
@@ -83,7 +83,7 @@ export function formatProfile(
|
|
|
83
83
|
}
|
|
84
84
|
const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
|
|
85
85
|
lines.push(
|
|
86
|
-
` ${role.padEnd(width)} ${`${rc.access}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
|
|
86
|
+
` ${role.padEnd(width)} ${`${rc.access}${rc.network === false ? " (no network)" : ""}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
// only what catherd can run today: a backend without an adapter has nothing to bill or isolate
|
|
@@ -94,9 +94,7 @@ export function formatProfile(
|
|
|
94
94
|
lines.push(`harness ${harness.map(([k, h]) => `${k} ${h.isolated ? "isolated" : "native"}`).join(" · ")}`);
|
|
95
95
|
lines.push(o.standIns.length ? "failover" : "failover none");
|
|
96
96
|
for (const s of o.standIns)
|
|
97
|
-
lines.push(
|
|
98
|
-
` ${s.from} → ${s.to}${s.inferred ? ` (inferred${s.via ? `: treated like ${s.via}` : ""})` : ""}`,
|
|
99
|
-
);
|
|
97
|
+
lines.push(` ${s.from} → ${s.to}${s.via ? ` (scores borrowed from ${s.via})` : ""}`);
|
|
100
98
|
const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
|
|
101
99
|
lines.push(`budget ${budget.join(" · ") || "no cap"}`);
|
|
102
100
|
lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
|
|
@@ -7,7 +7,8 @@ import { redact } from "../infra/log.ts";
|
|
|
7
7
|
import { cancel } from "../services/dispatch-service.ts";
|
|
8
8
|
import { registerSavedSecrets } from "../services/jev-service.ts";
|
|
9
9
|
import { runDebug } from "../services/run-debug.ts";
|
|
10
|
-
import { findRun, listRuns, readRecords } from "../services/run-store.ts";
|
|
10
|
+
import { findRun, listRuns, readRecords, type Run } from "../services/run-store.ts";
|
|
11
|
+
import { groupRuns, type RunSession, type SessionGroup } from "../services/session-view.ts";
|
|
11
12
|
import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
|
|
12
13
|
import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
13
14
|
import { defaultDeps } from "./deps.ts";
|
|
@@ -15,6 +16,15 @@ import { defaultDeps } from "./deps.ts";
|
|
|
15
16
|
const json = JSON_ARG;
|
|
16
17
|
const n = (x: number) => x.toLocaleString("en-US");
|
|
17
18
|
|
|
19
|
+
/** A session's heading in `runs list` and `status` (spec §4): `● live <name>` or `· idle <name>`. */
|
|
20
|
+
export function sessionHeading(s: RunSession | null): string {
|
|
21
|
+
return s ? `session ${s.live ? "● live" : "· idle"} ${s.name}` : "earlier runs";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How a run moved between sessions, after its line: `(continued here)`, `(continued in <name>)`. */
|
|
25
|
+
const movedNote = (g: SessionGroup["runs"][number]): string =>
|
|
26
|
+
g.continued === "here" ? " (continued here)" : g.continuedIn ? ` (continued in ${g.continuedIn})` : "";
|
|
27
|
+
|
|
18
28
|
/** One run at a glance: what is live, what finished, spend against the budget, landed milestones. */
|
|
19
29
|
export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
20
30
|
const t = s.totals;
|
|
@@ -22,6 +32,7 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
22
32
|
`run ${s.id} ${s.title}`,
|
|
23
33
|
` repo ${s.repo} · started ${s.createdAt} · ${Math.round((now - Date.parse(s.createdAt)) / 60_000)} min`,
|
|
24
34
|
];
|
|
35
|
+
for (const q of s.questions) lines.push(` ${mark("warn")} parked ${q.milestone}: ${q.question}`);
|
|
25
36
|
for (const l of s.live) lines.push(` live ${l.name} ${l.rung} ${l.state} ${l.secs}s`);
|
|
26
37
|
lines.push(
|
|
27
38
|
` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
|
|
@@ -37,6 +48,10 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
37
48
|
if (s.budget) lines.push(` budget ${formatBudget(s.budget)}`);
|
|
38
49
|
if (s.jev.decisions) lines.push(` jev ${s.jev.decisions} decision(s), ${s.jev.fallbacks} fallback(s)`);
|
|
39
50
|
for (const m of s.milestones) lines.push(` landed ${m}`);
|
|
51
|
+
if (s.verifier)
|
|
52
|
+
lines.push(
|
|
53
|
+
` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)}`,
|
|
54
|
+
);
|
|
40
55
|
for (const l of s.stateTail) lines.push(` | ${l}`);
|
|
41
56
|
for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
|
|
42
57
|
return lines;
|
|
@@ -48,7 +63,27 @@ function printStatus(runId: string | undefined, asJson: boolean): void {
|
|
|
48
63
|
const r = redact(status(defaultDeps(), runId));
|
|
49
64
|
if (asJson) return printJson(r);
|
|
50
65
|
if (r.runs.length === 0) console.log("no runs yet");
|
|
51
|
-
|
|
66
|
+
// grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
|
|
67
|
+
const byId = new Map(r.runs.map((s) => [s.id, s]));
|
|
68
|
+
const runs = r.runs.flatMap((s) => {
|
|
69
|
+
try {
|
|
70
|
+
return [findRun(s.id)];
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
for (const g of groupRuns(runs)) {
|
|
76
|
+
const own = g.runs.filter((x) => x.continued !== "here");
|
|
77
|
+
if (own.length === 0) continue;
|
|
78
|
+
console.log(sessionHeading(g.session));
|
|
79
|
+
for (const x of own) {
|
|
80
|
+
const s = byId.get(x.run.id);
|
|
81
|
+
if (!s) continue;
|
|
82
|
+
const [head, ...rest] = formatRun(s);
|
|
83
|
+
console.log(`${head}${movedNote(x)}`);
|
|
84
|
+
for (const l of rest) console.log(l);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
52
87
|
for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
|
|
53
88
|
}
|
|
54
89
|
|
|
@@ -124,25 +159,33 @@ const list = defineCommand({
|
|
|
124
159
|
fix: "pass a path inside the repo, or leave out --repo",
|
|
125
160
|
});
|
|
126
161
|
const { runs, corrupt } = listRuns();
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
const shown = runs.filter((r) => !args.repo || r.meta.repo === top);
|
|
163
|
+
const deps = defaultDeps();
|
|
164
|
+
const row = (r: Run) => {
|
|
165
|
+
const s = summarizeRun(deps, r);
|
|
166
|
+
return {
|
|
167
|
+
id: r.id,
|
|
168
|
+
title: r.meta.title,
|
|
169
|
+
repo: r.meta.repo,
|
|
170
|
+
createdAt: r.meta.createdAt,
|
|
171
|
+
live: s.live.length,
|
|
172
|
+
roleRuns: s.totals.runs,
|
|
173
|
+
session: s.session,
|
|
174
|
+
continuedIn: s.continuedIn,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
const groups = groupRuns(shown);
|
|
178
|
+
if (args.json) return printJson({ runs: shown.map(row), corrupt });
|
|
179
|
+
if (shown.length === 0) console.log("no runs yet");
|
|
180
|
+
for (const g of groups) {
|
|
181
|
+
console.log(sessionHeading(g.session));
|
|
182
|
+
for (const x of g.runs) {
|
|
183
|
+
const r = row(x.run);
|
|
184
|
+
console.log(
|
|
185
|
+
` ${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}${movedNote(x)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
146
189
|
for (const c of corrupt) console.log(`${mark("warn")} skipped run ${c.id}: ${c.reason}`);
|
|
147
190
|
},
|
|
148
191
|
});
|
package/src/entry/tui/effects.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { realpathSync, statSync, watch } from "node:fs";
|
|
2
2
|
import { adapterFor } from "../../adapters/registry.ts";
|
|
3
3
|
import "../../adapters/all.ts";
|
|
4
4
|
import { agentFiles } from "../../domain/agents.ts";
|
|
@@ -6,7 +6,6 @@ import type { Catalog } from "../../domain/catalog.ts";
|
|
|
6
6
|
import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
|
|
7
7
|
import { type Validation, validateProfile } from "../../domain/profile-rules.ts";
|
|
8
8
|
import type { Access } from "../../domain/record.ts";
|
|
9
|
-
import type { RouteSource } from "../../domain/route.ts";
|
|
10
9
|
import { VERSION } from "../../infra/version.ts";
|
|
11
10
|
import {
|
|
12
11
|
type CatalogModel,
|
|
@@ -37,13 +36,32 @@ import {
|
|
|
37
36
|
readProjects,
|
|
38
37
|
runnableBackends,
|
|
39
38
|
} from "../../services/profile-store.ts";
|
|
40
|
-
import {
|
|
39
|
+
import { listRuns, type Run, runPaths } from "../../services/run-store.ts";
|
|
40
|
+
import {
|
|
41
|
+
type MilestoneDetail,
|
|
42
|
+
milestoneDetail,
|
|
43
|
+
type RoleDetail,
|
|
44
|
+
roleDetail,
|
|
45
|
+
type SessionDetail,
|
|
46
|
+
sessionDetail,
|
|
47
|
+
type SessionRow,
|
|
48
|
+
sessionRows,
|
|
49
|
+
} from "../../services/runs-page.ts";
|
|
41
50
|
import { type RunSummary, summarizeRun } from "../../services/summary.ts";
|
|
42
51
|
import { defaultDeps } from "../deps.ts";
|
|
43
52
|
import { mcpHandshake } from "../mcp/handshake.ts";
|
|
44
53
|
|
|
45
54
|
/** A save refused because the profile changed on disk since its preview read it carries this path. */
|
|
46
55
|
export { CHANGED_ON_DISK } from "../../services/profile-service.ts";
|
|
56
|
+
export type {
|
|
57
|
+
Milestone,
|
|
58
|
+
MilestoneDetail,
|
|
59
|
+
RoleDetail,
|
|
60
|
+
RoleRow,
|
|
61
|
+
SessionDetail,
|
|
62
|
+
SessionRow,
|
|
63
|
+
SessionRun,
|
|
64
|
+
} from "../../services/runs-page.ts";
|
|
47
65
|
|
|
48
66
|
/** One line of the run list. */
|
|
49
67
|
export interface RunRow {
|
|
@@ -56,31 +74,8 @@ export interface RunRow {
|
|
|
56
74
|
landed: number;
|
|
57
75
|
/** the budget's spent fraction, null without a cap */
|
|
58
76
|
budget: number | null;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
interface Climb {
|
|
62
|
-
lane: string;
|
|
63
|
-
from: string;
|
|
64
|
-
to: string;
|
|
65
|
-
reason: string;
|
|
66
|
-
/** the environment's fault (a limit, a missing service), not the rung's */
|
|
67
|
-
env: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface Decision {
|
|
71
|
-
lane: string;
|
|
72
|
-
role: string;
|
|
73
|
-
source: RouteSource;
|
|
74
|
-
kind: string | null;
|
|
75
|
-
difficulty: string | null;
|
|
76
|
-
rung: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Spec §9.1's run view: live lanes, climbs, Jev decisions, budget, landed milestones. */
|
|
80
|
-
export interface RunDetail {
|
|
81
|
-
summary: RunSummary;
|
|
82
|
-
climbs: Climb[];
|
|
83
|
-
decisions: Decision[];
|
|
77
|
+
/** the session that started it (the Runs tab opens it); null for a run from before 1.1 */
|
|
78
|
+
session: string | null;
|
|
84
79
|
}
|
|
85
80
|
|
|
86
81
|
/**
|
|
@@ -91,7 +86,19 @@ export interface Effects {
|
|
|
91
86
|
version: string;
|
|
92
87
|
doctor(): Promise<DoctorReport>;
|
|
93
88
|
runs(): { rows: RunRow[]; warnings: string[] };
|
|
94
|
-
|
|
89
|
+
/** spec §4: the Runs tab's top level, the sessions */
|
|
90
|
+
sessions(): { rows: SessionRow[]; warnings: string[] };
|
|
91
|
+
/** a session's screen; `key` null is "earlier runs" */
|
|
92
|
+
session(key: string | null): SessionDetail;
|
|
93
|
+
/** a role's screen */
|
|
94
|
+
role(run: string, dispatchId: string): RoleDetail;
|
|
95
|
+
/** spec 1.1 §10: a milestone's screen, its digest */
|
|
96
|
+
milestone(run: string, name: string): MilestoneDetail;
|
|
97
|
+
/**
|
|
98
|
+
* calls `onChange` when a file under one of `dirs` changes (spec §4: the open screen redraws when a run file
|
|
99
|
+
* changes); null when they cannot be watched here, and the screen polls every second instead
|
|
100
|
+
*/
|
|
101
|
+
watch(dirs: string[], onChange: () => void): (() => void) | null;
|
|
95
102
|
/** stops a live role; resolves to the line the toast shows */
|
|
96
103
|
cancel(run: string, name: string): Promise<string>;
|
|
97
104
|
profiles(): {
|
|
@@ -193,33 +200,67 @@ export function rowOf(s: RunSummary): RunRow {
|
|
|
193
200
|
roleRuns: s.totals.runs,
|
|
194
201
|
landed: s.milestones.length,
|
|
195
202
|
budget: s.budget?.fraction ?? null,
|
|
203
|
+
session: s.session?.sessionId ?? null,
|
|
196
204
|
};
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
207
|
+
/** How long a burst of file events is gathered into one redraw. */
|
|
208
|
+
const WATCH_SETTLE_MS = 100;
|
|
209
|
+
|
|
210
|
+
/** What `watchDirs` watches and times with; tests pass fakes. */
|
|
211
|
+
export interface WatchPorts {
|
|
212
|
+
watch(dir: string, fire: () => void): { on(event: "error", f: () => void): unknown; close(): void };
|
|
213
|
+
setTimeout(f: () => void, ms: number): unknown;
|
|
214
|
+
clearTimeout(h: unknown): void;
|
|
215
|
+
/** the folder's real path: macOS reports FSEvents under it (/var is /private/var) */
|
|
216
|
+
realpath(dir: string): string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const LIVE_WATCH: WatchPorts = {
|
|
220
|
+
watch: (dir, fire) => watch(dir, { recursive: true }, fire),
|
|
221
|
+
setTimeout: (f, ms) => setTimeout(f, ms),
|
|
222
|
+
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
223
|
+
realpath: (dir) => realpathSync(dir),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `fs.watch` on each run folder's real path, recursive; null when any of them cannot be watched. After the
|
|
228
|
+
* returned stop, `onChange` is never called, even for an event already queued.
|
|
229
|
+
*/
|
|
230
|
+
export function watchDirs(
|
|
231
|
+
dirs: string[],
|
|
232
|
+
onChange: () => void,
|
|
233
|
+
ports: Partial<WatchPorts> = {},
|
|
234
|
+
): (() => void) | null {
|
|
235
|
+
const io = { ...LIVE_WATCH, ...ports };
|
|
236
|
+
const watchers: { close(): void }[] = [];
|
|
237
|
+
let timer: unknown = null;
|
|
238
|
+
let stopped = false;
|
|
239
|
+
const fire = () => {
|
|
240
|
+
if (stopped || timer !== null) return;
|
|
241
|
+
timer = io.setTimeout(() => {
|
|
242
|
+
timer = null;
|
|
243
|
+
if (!stopped) onChange();
|
|
244
|
+
}, WATCH_SETTLE_MS);
|
|
222
245
|
};
|
|
246
|
+
const stop = () => {
|
|
247
|
+
stopped = true;
|
|
248
|
+
if (timer !== null) io.clearTimeout(timer);
|
|
249
|
+
timer = null;
|
|
250
|
+
for (const w of watchers) w.close();
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
const w = io.watch(io.realpath(d), fire);
|
|
255
|
+
// a watcher that breaks later (the folder removed) just stops; the screen's slow poll still reads
|
|
256
|
+
w.on("error", () => w.close());
|
|
257
|
+
watchers.push(w);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
stop();
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
return stop;
|
|
223
264
|
}
|
|
224
265
|
|
|
225
266
|
/**
|
|
@@ -238,10 +279,11 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
238
279
|
const { runs, corrupt } = listRuns();
|
|
239
280
|
return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
|
|
240
281
|
},
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
282
|
+
sessions: () => sessionRows(deps),
|
|
283
|
+
session: (key) => sessionDetail(deps, key),
|
|
284
|
+
role: (run, dispatchId) => roleDetail(deps, run, dispatchId),
|
|
285
|
+
milestone: (run, name) => milestoneDetail(run, name),
|
|
286
|
+
watch: watchDirs,
|
|
245
287
|
async cancel(run, name) {
|
|
246
288
|
const r = await cancel(deps, run, name);
|
|
247
289
|
return `${r.record.name} ${r.record.status}`;
|