catherd-cli 0.2.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- 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 +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -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/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
5
|
+
import { git } from "../infra/git.ts";
|
|
6
|
+
import { nonBlankLines } from "../infra/store.ts";
|
|
7
|
+
import { listDispatches } from "./dispatches.ts";
|
|
8
|
+
import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
|
|
9
|
+
|
|
10
|
+
// Spec 1.1 §6 and §10: what a milestone has been through, read from the run's own records. `land` gates on
|
|
11
|
+
// it, and the protocol's next step is derived from it.
|
|
12
|
+
|
|
13
|
+
/** Where a milestone name may end inside a longer name: the end, '-', '.', '_' or whitespace (M1-fix, M1.2). */
|
|
14
|
+
const MILESTONE_END = String.raw`(?=$|[-._\s])`;
|
|
15
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
|
|
17
|
+
/** `name` names milestone `m` as a word: verifier-M1 and M1-verifier do; verifier-M10 and verifier-M1fix do not. */
|
|
18
|
+
export const namesMilestone = (name: string, m: string): boolean =>
|
|
19
|
+
new RegExp(`(^|[^A-Za-z0-9])${escapeRe(m)}${MILESTONE_END}`).test(name);
|
|
20
|
+
|
|
21
|
+
/** `name` is milestone `m`'s reviewer: reviewer-<m>, the milestone ending as in namesMilestone (reviewer-M10 is not M1's). */
|
|
22
|
+
export const reviewsMilestone = (name: string, m: string): boolean =>
|
|
23
|
+
new RegExp(`^reviewer-${escapeRe(m)}${MILESTONE_END}`).test(name);
|
|
24
|
+
|
|
25
|
+
const inMilestone = (lane: string | null | undefined, m: string): boolean =>
|
|
26
|
+
typeof lane === "string" && lane.startsWith(`${m}.`);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* When the milestone's lanes started: its earliest route row or lane dispatch; null when neither exists
|
|
30
|
+
* (a milestone with no lanes, such as a docs pass), and every record of the run then counts.
|
|
31
|
+
*/
|
|
32
|
+
export function milestoneStart(run: Run, m: string): string | null {
|
|
33
|
+
const times = [
|
|
34
|
+
...readRoutes(run)
|
|
35
|
+
.filter((r) => inMilestone(r.lane, m))
|
|
36
|
+
.map((r) => r.at),
|
|
37
|
+
...listDispatches(run)
|
|
38
|
+
.filter((d) => inMilestone(d.admit.lane, m))
|
|
39
|
+
.map((d) => d.admit.admittedAt),
|
|
40
|
+
].filter((t) => !Number.isNaN(Date.parse(t)));
|
|
41
|
+
return times.sort()[0] ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const since = (at: string, start: string | null) => start === null || Date.parse(at) >= Date.parse(start);
|
|
45
|
+
|
|
46
|
+
/** The milestone's reviewer: its dispatch record, or null for a native Claude subagent (record_agent_run). */
|
|
47
|
+
export interface MilestoneReviewer {
|
|
48
|
+
name: string;
|
|
49
|
+
at: string;
|
|
50
|
+
record: RunRecord | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
|
|
55
|
+
* ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
|
|
56
|
+
*/
|
|
57
|
+
export function milestoneReviewer(
|
|
58
|
+
run: Run,
|
|
59
|
+
m: string,
|
|
60
|
+
start = milestoneStart(run, m),
|
|
61
|
+
): MilestoneReviewer | null {
|
|
62
|
+
const found: MilestoneReviewer[] = [
|
|
63
|
+
...readRecords(run)
|
|
64
|
+
.records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
|
|
65
|
+
.map((r) => ({ name: r.name, at: r.endedAt, record: r })),
|
|
66
|
+
...readAgentRuns(run)
|
|
67
|
+
.filter(
|
|
68
|
+
(a) =>
|
|
69
|
+
a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
|
|
70
|
+
)
|
|
71
|
+
.map((a) => ({ name: a.name, at: a.at, record: null })),
|
|
72
|
+
];
|
|
73
|
+
return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
|
|
77
|
+
export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run, m)): boolean =>
|
|
78
|
+
milestoneReviewer(run, m, start) !== null;
|
|
79
|
+
|
|
80
|
+
/** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
|
|
81
|
+
const VERDICT_PASS = /^VERDICT: PASS\b/;
|
|
82
|
+
|
|
83
|
+
/** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
|
|
84
|
+
export function replyVerdict(run: Run, r: RunRecord): string | null {
|
|
85
|
+
const file = join(run.dir, r.replyPath);
|
|
86
|
+
if (!r.replyPath || !existsSync(file)) return null;
|
|
87
|
+
return (
|
|
88
|
+
readFileSync(file, "utf8")
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((l) => l.trim())
|
|
91
|
+
.find(Boolean) ?? null
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A headless verifier's reply opens with VERDICT: PASS: its first non-blank line; no reply is no verdict. */
|
|
96
|
+
export function replyPasses(run: Run, r: RunRecord): boolean {
|
|
97
|
+
const first = replyVerdict(run, r);
|
|
98
|
+
return first !== null && VERDICT_PASS.test(first);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The milestone's latest verifier attempt: a native Claude subagent or a headless dispatch, passed or not. */
|
|
102
|
+
export interface MilestoneVerifier {
|
|
103
|
+
name: string;
|
|
104
|
+
at: string;
|
|
105
|
+
headless: boolean;
|
|
106
|
+
passed: boolean;
|
|
107
|
+
/** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
|
|
108
|
+
verdict: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
|
|
113
|
+
* with role verifier whose name names the milestone (the skill records a FAIL as failed), passing when its
|
|
114
|
+
* status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
|
|
115
|
+
* reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
|
|
116
|
+
* when there is none.
|
|
117
|
+
*/
|
|
118
|
+
export function milestoneVerifier(
|
|
119
|
+
run: Run,
|
|
120
|
+
m: string,
|
|
121
|
+
start = milestoneStart(run, m),
|
|
122
|
+
): MilestoneVerifier | null {
|
|
123
|
+
const found: MilestoneVerifier[] = [
|
|
124
|
+
...readAgentRuns(run)
|
|
125
|
+
.filter((a) => a.role === "verifier" && namesMilestone(a.name, m) && since(a.at, start))
|
|
126
|
+
.map((a) => ({
|
|
127
|
+
name: a.name,
|
|
128
|
+
at: a.at,
|
|
129
|
+
headless: false,
|
|
130
|
+
passed: a.status === "ok",
|
|
131
|
+
verdict: a.status,
|
|
132
|
+
})),
|
|
133
|
+
...readRecords(run)
|
|
134
|
+
.records.filter((r) => r.role === "verifier" && namesMilestone(r.name, m) && since(r.endedAt, start))
|
|
135
|
+
.map((r) => {
|
|
136
|
+
const first = replyVerdict(run, r);
|
|
137
|
+
const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
|
|
138
|
+
const said = first === null ? "no reply" : first.length > 80 ? `${first.slice(0, 80)}…` : first;
|
|
139
|
+
return {
|
|
140
|
+
name: r.name,
|
|
141
|
+
at: r.endedAt,
|
|
142
|
+
headless: true,
|
|
143
|
+
passed,
|
|
144
|
+
verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
// a stable sort: attempts at the same instant keep their file order, the later row winning
|
|
149
|
+
return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The milestone's latest verifier attempt since its lanes started passed (see milestoneVerifier). */
|
|
153
|
+
export const verifierPassed = (run: Run, m: string, start = milestoneStart(run, m)): boolean =>
|
|
154
|
+
milestoneVerifier(run, m, start)?.passed === true;
|
|
155
|
+
|
|
156
|
+
/** The commits of the landed milestones, oldest first, from the ledger. */
|
|
157
|
+
export function landedCommits(run: Run): string[] {
|
|
158
|
+
return nonBlankLines(runPaths(run.dir).ledger)
|
|
159
|
+
.slice(1)
|
|
160
|
+
.map((row) => row.split(" | ")[2]?.trim() ?? "")
|
|
161
|
+
.filter((c) => /^[0-9a-f]{7,40}$/.test(c));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The milestones the ledger holds, in landing order. */
|
|
165
|
+
export function landedMilestones(run: Run): string[] {
|
|
166
|
+
return nonBlankLines(runPaths(run.dir).ledger)
|
|
167
|
+
.slice(1)
|
|
168
|
+
.map((row) => row.split(" | ")[0]?.trim() ?? "")
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The files the milestone's commit range changed: from the previous landed commit (else the commit's own
|
|
174
|
+
* parent) to `commit`. Throws E_IO_UNEXPECTED when git cannot say.
|
|
175
|
+
*/
|
|
176
|
+
export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
|
|
177
|
+
const base = landedCommits(run).at(-1);
|
|
178
|
+
const repo = run.meta.repo;
|
|
179
|
+
const r = base
|
|
180
|
+
? await git(repo, ["diff", "--name-only", base, commit])
|
|
181
|
+
: await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
|
|
182
|
+
if (r.kind !== "ok")
|
|
183
|
+
throw new CatherdError("E_IO_UNEXPECTED", `git could not list the files ${commit} changed`, {
|
|
184
|
+
fix: `check that git works in ${repo}`,
|
|
185
|
+
});
|
|
186
|
+
return r.out.split("\n").filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
|
|
190
|
+
export async function fullCommit(run: Run, rev: string): Promise<string> {
|
|
191
|
+
const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
|
192
|
+
const sha = r.kind === "ok" ? r.out.trim() : "";
|
|
193
|
+
if (!sha)
|
|
194
|
+
throw new CatherdError("E_IO_UNEXPECTED", `git could not resolve ${rev} in ${run.meta.repo}`, {
|
|
195
|
+
fix: `check that git works in ${run.meta.repo}`,
|
|
196
|
+
});
|
|
197
|
+
return sha;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** the repo's own docs/ folder, or a doc extension anywhere: app/docs/page.tsx is code */
|
|
201
|
+
const DOC = /^docs\/|\.(md|mdx|markdown|txt|rst|adoc)$/i;
|
|
202
|
+
/** .txt files that configure a build or pin dependencies: not docs */
|
|
203
|
+
const TXT_MANIFEST = /(^|\/)(requirements[^/]*|constraints[^/]*|CMakeLists)\.txt$/i;
|
|
204
|
+
const SOURCE =
|
|
205
|
+
/\.(ts|tsx|js|jsx|mjs|cjs|go|py|rs|java|kt|kts|swift|rb|php|c|h|cc|cpp|hpp|cs|m|scala|sh|bash|zsh|sql|vue|svelte|css|scss|sass|less|html|dart|ex|exs|erl|zig|lua)$/i;
|
|
206
|
+
|
|
207
|
+
export const isDocPath = (p: string): boolean => DOC.test(p) && !TXT_MANIFEST.test(p);
|
|
208
|
+
export const isSourcePath = (p: string): boolean => SOURCE.test(p) && !isDocPath(p);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
4
|
+
import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
|
|
5
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
6
|
+
import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
8
|
+
import { log } from "../infra/log.ts";
|
|
9
|
+
import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
|
|
10
|
+
import { writeJsonAtomic } from "../infra/store.ts";
|
|
11
|
+
import { type Settled, settledHooks, type Stalled, stallHooks } from "./dispatch-service.ts";
|
|
12
|
+
import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
|
|
13
|
+
import type { Deps } from "./ports.ts";
|
|
14
|
+
import { listRuns, readRecords, type Run } from "./run-store.ts";
|
|
15
|
+
import { currentSession, ownsRun } from "./sessions.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Spec §3.4: runs only inside the MCP server, the session's child and so its only sender (§3.2). For every run this
|
|
19
|
+
* session owns, a dispatch that is settled with its record still unread is announced to the session's peer inbox;
|
|
20
|
+
* notices that arrive within the window of each other go as one message; a message that went out is written down
|
|
21
|
+
* (`notified.json`), so a restart never sends it twice, and claimed while it goes, so two servers of one session never
|
|
22
|
+
* both send it. Ownership is asked again under the claim and once more after the message went: a run another session
|
|
23
|
+
* claimed meanwhile is left unmarked, so its new owner announces it too (a copy to the session that gave the run up is
|
|
24
|
+
* acceptable, a notice lost to the owner is not); a notice whose claim another server holds is tried again after the
|
|
25
|
+
* window until it is sent, read, marked or no longer this session's. Disk stays the truth: a notice that cannot be
|
|
26
|
+
* sent is dropped (its claim freed), and the record waits, unread, for `result` or `peek`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface NotifierOptions {
|
|
30
|
+
/** notices this close to each other go as one message (spec: 3 s); tests shorten it */
|
|
31
|
+
coalesceMs?: number;
|
|
32
|
+
/** the sender; tests may replace it */
|
|
33
|
+
send?: (
|
|
34
|
+
target: { socketPath: string | null; token: string | null },
|
|
35
|
+
content: string,
|
|
36
|
+
p: NoticePriority,
|
|
37
|
+
) => Promise<SendResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Notifier {
|
|
41
|
+
/** the settled hook: queues the dispatch's notice when this session should hear of it */
|
|
42
|
+
onSettled(s: Settled): void;
|
|
43
|
+
/** the stall hook: queues a stalled role's notice, once per dispatch */
|
|
44
|
+
onStall(s: Stalled): void;
|
|
45
|
+
/** spec §3.4 "a scan on start": every unread, un-notified record of a run this session owns */
|
|
46
|
+
scan(): Promise<void>;
|
|
47
|
+
/** resolves once nothing is queued and no message is on its way */
|
|
48
|
+
idle(): Promise<void>;
|
|
49
|
+
/** removes the hook and drops what is queued */
|
|
50
|
+
stop(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const notified = (dir: string): boolean => existsSync(dispatchPaths(dir).notified);
|
|
54
|
+
|
|
55
|
+
function replyOf(run: Run, r: RunRecord): string {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(join(run.dir, r.replyPath), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Spec §3.5/§3.6: one finished role's notice, with what failover did in place of a limit's status. */
|
|
64
|
+
export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
|
|
65
|
+
const fo = readFailover(d.dir);
|
|
66
|
+
let status: string = r.status;
|
|
67
|
+
if (r.status === "limit") {
|
|
68
|
+
const why = fo?.standIn
|
|
69
|
+
? `failed over to ${fo.standIn.rung}`
|
|
70
|
+
: fo?.hints.some((h) => h.startsWith("failover: run "))
|
|
71
|
+
? "its stand-in is a Claude agent: result(run, name) has the hint"
|
|
72
|
+
: fo?.hints.some((h) => h.startsWith("failover: "))
|
|
73
|
+
? "paused: the stand-in was refused"
|
|
74
|
+
: "paused: no stand-in";
|
|
75
|
+
status = `limit on ${r.rung}; ${why}`;
|
|
76
|
+
}
|
|
77
|
+
const urgent = r.status === "limit" || r.replyStatus === "blocked" || r.replyStatus === "refused";
|
|
78
|
+
return {
|
|
79
|
+
kind: "finished",
|
|
80
|
+
runId: run.id,
|
|
81
|
+
runTitle: run.meta.title,
|
|
82
|
+
dispatchId: r.dispatchId,
|
|
83
|
+
name: r.name,
|
|
84
|
+
role: r.role,
|
|
85
|
+
lane: r.lane,
|
|
86
|
+
rung: r.rung,
|
|
87
|
+
status,
|
|
88
|
+
replyStatus: r.replyStatus,
|
|
89
|
+
secs: r.secs,
|
|
90
|
+
changedOwned: r.changedOwned.length,
|
|
91
|
+
reply: replyOf(run, r),
|
|
92
|
+
priority: urgent ? "next" : "later",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Spec §3.6: a live role that went quiet, once, at `next`. */
|
|
97
|
+
export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
|
|
98
|
+
return {
|
|
99
|
+
kind: "stalled",
|
|
100
|
+
runId: run.id,
|
|
101
|
+
runTitle: run.meta.title,
|
|
102
|
+
dispatchId: d.admit.dispatchId,
|
|
103
|
+
name: d.admit.name,
|
|
104
|
+
role: d.admit.role,
|
|
105
|
+
lane: d.admit.lane,
|
|
106
|
+
rung: d.admit.rung,
|
|
107
|
+
status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
|
|
108
|
+
replyStatus: null,
|
|
109
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
110
|
+
changedOwned: 0,
|
|
111
|
+
reply: "",
|
|
112
|
+
priority: "next",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Queued {
|
|
117
|
+
/** its key in the queue */
|
|
118
|
+
key: string;
|
|
119
|
+
/** the run it is about: its owner may change before the message goes */
|
|
120
|
+
run: Run;
|
|
121
|
+
notice: Notice;
|
|
122
|
+
/** the mark a message that went out leaves */
|
|
123
|
+
mark: string;
|
|
124
|
+
/** whether it is still news when the message goes */
|
|
125
|
+
due: () => boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Starts the notifier for this process's session and hooks it to every settled dispatch. */
|
|
129
|
+
export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
|
|
130
|
+
const window = o.coalesceMs ?? 3_000;
|
|
131
|
+
const send = o.send ?? sendToInbox;
|
|
132
|
+
const queue = new Map<string, Queued>();
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
134
|
+
let firstAt = 0;
|
|
135
|
+
let flight: Promise<void> = Promise.resolve();
|
|
136
|
+
let stopped = false;
|
|
137
|
+
|
|
138
|
+
/** This session's id, when there is one and it owns the run. */
|
|
139
|
+
const owned = (run: Run): boolean => ownsRun(deps, run);
|
|
140
|
+
|
|
141
|
+
const flush = (): void => {
|
|
142
|
+
timer = null;
|
|
143
|
+
const batch = [...queue.values()];
|
|
144
|
+
queue.clear();
|
|
145
|
+
// a delivery that throws (the notified.json write, say) is logged and never stops the ones after it
|
|
146
|
+
flight = flight
|
|
147
|
+
.then(() => deliver(batch))
|
|
148
|
+
.catch((e: unknown) =>
|
|
149
|
+
log("warn", "notify", { error: errorMessage(e), dispatches: batch.map((q) => q.notice.dispatchId) }),
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const schedule = (): void => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (timer === null) firstAt = now;
|
|
156
|
+
else clearTimeout(timer);
|
|
157
|
+
// quiet for `window`, but never held past five windows: a steady trickle still goes out
|
|
158
|
+
timer = setTimeout(flush, Math.max(0, Math.min(window, firstAt + 5 * window - now)));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
async function deliver(batch: Queued[]): Promise<void> {
|
|
162
|
+
if (!deps.session) return;
|
|
163
|
+
// Each notice is claimed before it goes (an exclusive lock on its mark, freed if its holder dies): two servers
|
|
164
|
+
// of one session, a replacement starting while the old one still watches, never both send it. Whether it is
|
|
165
|
+
// still news is asked under the claim: the other may have announced it just before letting go.
|
|
166
|
+
const claims: (() => void)[] = [];
|
|
167
|
+
const due: Queued[] = [];
|
|
168
|
+
const busy: Queued[] = [];
|
|
169
|
+
try {
|
|
170
|
+
for (const q of batch) {
|
|
171
|
+
// read (or announced) meanwhile, or the run moved to another session (whose server tells it)
|
|
172
|
+
if (!q.due() || !owned(q.run)) continue;
|
|
173
|
+
const release = tryLock(q.mark);
|
|
174
|
+
// another server is announcing it: asked again after the window, since that server may leave it unmarked
|
|
175
|
+
// (the run moved to this session while its message was on its way)
|
|
176
|
+
if (!release) {
|
|
177
|
+
busy.push(q);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
claims.push(release);
|
|
181
|
+
// asked again under the claim: the run may have moved, or the notice gone out, while it was taken
|
|
182
|
+
if (q.due() && owned(q.run)) due.push(q);
|
|
183
|
+
}
|
|
184
|
+
if (due.length === 0) return;
|
|
185
|
+
const notices = due.map((q) => q.notice);
|
|
186
|
+
const r = await send(
|
|
187
|
+
{ socketPath: deps.session.socketPath, token: deps.session.token },
|
|
188
|
+
envelope(formatNotices(notices)),
|
|
189
|
+
priorityOf(notices),
|
|
190
|
+
).catch((e: unknown): SendResult => ({ outcome: "error", reason: errorMessage(e) }));
|
|
191
|
+
if (r.outcome !== "sent") {
|
|
192
|
+
log("warn", "notify", {
|
|
193
|
+
outcome: r.outcome,
|
|
194
|
+
reason: r.reason,
|
|
195
|
+
dispatches: notices.map((n) => n.dispatchId),
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const at = new Date(deps.now()).toISOString();
|
|
200
|
+
// a run that moved while the message was on its way is left unmarked: its new owner announces it too
|
|
201
|
+
const marked = due.filter((q) => owned(q.run));
|
|
202
|
+
for (const q of marked) writeJsonAtomic(q.mark, { schema: 1, msgId: r.msgId, at });
|
|
203
|
+
const unmarked = due.filter((q) => !marked.includes(q)).map((q) => q.notice.dispatchId);
|
|
204
|
+
log("info", "notify", { msgId: r.msgId, dispatches: notices.map((n) => n.dispatchId), unmarked });
|
|
205
|
+
} finally {
|
|
206
|
+
for (const release of claims) release();
|
|
207
|
+
requeue(busy);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Queues again the notices another server held the claim on; the next pass drops those no longer due or owned. */
|
|
212
|
+
function requeue(busy: Queued[]): void {
|
|
213
|
+
if (stopped || busy.length === 0) return;
|
|
214
|
+
for (const q of busy) if (!queue.has(q.key)) queue.set(q.key, q);
|
|
215
|
+
schedule();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const enqueue = (run: Run, d: Dispatch, record: RunRecord): void => {
|
|
219
|
+
if (queue.has(record.dispatchId) || notified(d.dir) || !awaitsCollect(d.dir) || !owned(run)) return;
|
|
220
|
+
queue.set(record.dispatchId, {
|
|
221
|
+
key: record.dispatchId,
|
|
222
|
+
run,
|
|
223
|
+
notice: finishedNotice(run, d, record),
|
|
224
|
+
mark: dispatchPaths(d.dir).notified,
|
|
225
|
+
due: () => awaitsCollect(d.dir) && !notified(d.dir),
|
|
226
|
+
});
|
|
227
|
+
schedule();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const onStall = (s: Stalled): void => {
|
|
231
|
+
try {
|
|
232
|
+
const p = dispatchPaths(s.d.dir);
|
|
233
|
+
const key = `${s.d.admit.dispatchId} stall`;
|
|
234
|
+
if (queue.has(key) || existsSync(p.stallNotified) || existsSync(p.exit) || !owned(s.run)) return;
|
|
235
|
+
queue.set(key, {
|
|
236
|
+
key,
|
|
237
|
+
run: s.run,
|
|
238
|
+
notice: stalledNotice(s.run, s.d, s.quietMs, deps.now()),
|
|
239
|
+
mark: p.stallNotified,
|
|
240
|
+
// it finished meanwhile: its record is the news now
|
|
241
|
+
due: () => !existsSync(p.stallNotified) && !existsSync(p.exit),
|
|
242
|
+
});
|
|
243
|
+
schedule();
|
|
244
|
+
} catch (e) {
|
|
245
|
+
log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const onSettled = (s: Settled): void => {
|
|
250
|
+
try {
|
|
251
|
+
enqueue(s.run, s.d, s.record);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const n: Notifier = {
|
|
258
|
+
onSettled,
|
|
259
|
+
onStall,
|
|
260
|
+
async scan() {
|
|
261
|
+
if (!currentSession(deps)) return;
|
|
262
|
+
for (const run of listRuns().runs) {
|
|
263
|
+
try {
|
|
264
|
+
if (!owned(run)) continue;
|
|
265
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
266
|
+
for (const d of listDispatches(run)) {
|
|
267
|
+
const r = records.get(d.admit.dispatchId);
|
|
268
|
+
// a limit not yet failed over is reconcile's to settle first: its hook announces it then
|
|
269
|
+
if (r && !(r.status === "limit" && !readFailover(d.dir))) enqueue(run, d, r);
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
log("warn", "notify", { run: run.id, error: errorMessage(e) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async idle() {
|
|
277
|
+
for (;;) {
|
|
278
|
+
if (timer === null && queue.size === 0) {
|
|
279
|
+
await flight;
|
|
280
|
+
if (timer === null && queue.size === 0) return;
|
|
281
|
+
} else await Bun.sleep(5);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
stop() {
|
|
285
|
+
stopped = true;
|
|
286
|
+
settledHooks.delete(onSettled);
|
|
287
|
+
stallHooks.delete(onStall);
|
|
288
|
+
if (timer !== null) clearTimeout(timer);
|
|
289
|
+
timer = null;
|
|
290
|
+
queue.clear();
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
settledHooks.add(onSettled);
|
|
294
|
+
stallHooks.add(onStall);
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { assertId } from "../domain/ids.ts";
|
|
2
|
+
import { noticeHeader } from "../domain/notice.ts";
|
|
3
|
+
import { awaitsCollect } from "../infra/dispatch-dir.ts";
|
|
4
|
+
import { claim } from "./dispatch-service.ts";
|
|
5
|
+
import { type Dispatch, type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
6
|
+
import { lastActivity } from "./finalize.ts";
|
|
7
|
+
import { finishedNotice } from "./notifier.ts";
|
|
8
|
+
import type { Deps } from "./ports.ts";
|
|
9
|
+
import { type Reentry, reentry } from "./reentry.ts";
|
|
10
|
+
import { findRun, listRuns, readAgentRuns, readRecords, type Run } from "./run-store.ts";
|
|
11
|
+
import { currentSession, runOwner } from "./sessions.ts";
|
|
12
|
+
import { readNotes } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
export interface PeekRole {
|
|
15
|
+
name: string;
|
|
16
|
+
role: string;
|
|
17
|
+
rung: string;
|
|
18
|
+
state: DispatchState;
|
|
19
|
+
/** since admission */
|
|
20
|
+
secs: number;
|
|
21
|
+
/** the last command, file edit or message line, else the last event's name; null before any event */
|
|
22
|
+
lastEvent: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Spec 1.1 §8/§10: the open owner questions come first, then the run, its protocol step and the verifier's step. */
|
|
26
|
+
export interface PeekRun extends Reentry {
|
|
27
|
+
run: string;
|
|
28
|
+
title: string;
|
|
29
|
+
/** the session that owns the run now, null when none has */
|
|
30
|
+
owner: string | null;
|
|
31
|
+
live: PeekRole[];
|
|
32
|
+
/** each finished record not yet read, as the first line of its message */
|
|
33
|
+
unread: { name: string; dispatchId: string; header: string }[];
|
|
34
|
+
/** the latest native Claude run recorded with record_agent_run */
|
|
35
|
+
native: { name: string; role: string; rung: string; status: string; at: string } | null;
|
|
36
|
+
/** the run's next step (state.md's last line) */
|
|
37
|
+
next: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
41
|
+
const now = deps.now();
|
|
42
|
+
const mine = (d: Dispatch) => name === undefined || d.admit.name === name;
|
|
43
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
44
|
+
const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
|
|
45
|
+
const native = agents.at(-1);
|
|
46
|
+
const r = reentry(run, now);
|
|
47
|
+
return {
|
|
48
|
+
questions: r.questions,
|
|
49
|
+
run: run.id,
|
|
50
|
+
title: run.meta.title,
|
|
51
|
+
owner: runOwner(run)?.sessionId ?? null,
|
|
52
|
+
live: liveDispatches(run, now)
|
|
53
|
+
.filter(mine)
|
|
54
|
+
.map((d) => ({
|
|
55
|
+
name: d.admit.name,
|
|
56
|
+
role: d.admit.role,
|
|
57
|
+
rung: d.admit.rung,
|
|
58
|
+
state: d.state,
|
|
59
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
60
|
+
lastEvent: lastActivity(d),
|
|
61
|
+
})),
|
|
62
|
+
unread: listDispatches(run)
|
|
63
|
+
.filter(mine)
|
|
64
|
+
.flatMap((d) => {
|
|
65
|
+
const r = records.get(d.admit.dispatchId);
|
|
66
|
+
return r && awaitsCollect(d.dir)
|
|
67
|
+
? [{ name: r.name, dispatchId: r.dispatchId, header: noticeHeader(finishedNotice(run, d, r)) }]
|
|
68
|
+
: [];
|
|
69
|
+
}),
|
|
70
|
+
native: native
|
|
71
|
+
? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
|
|
72
|
+
: null,
|
|
73
|
+
next: readNotes(run).next,
|
|
74
|
+
protocol: r.protocol,
|
|
75
|
+
verifier: r.verifier,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spec §3.7 `peek(run?, name?)`: never waits, never marks a record read. With a run it makes this session the
|
|
81
|
+
* run's owner (spec §3.3) and watches the roles another session's server launched; without one it shows every run
|
|
82
|
+
* this session owns, else the newest run.
|
|
83
|
+
*/
|
|
84
|
+
export async function peek(
|
|
85
|
+
deps: Deps,
|
|
86
|
+
i: { run?: string; name?: string },
|
|
87
|
+
): Promise<{ runs: PeekRun[]; hints: string[] }> {
|
|
88
|
+
if (i.name !== undefined) assertId("role name", i.name);
|
|
89
|
+
let runs: Run[];
|
|
90
|
+
if (i.run) {
|
|
91
|
+
const run = findRun(i.run);
|
|
92
|
+
// ownership now; what the run's earlier owner left is taken care of after the call returns
|
|
93
|
+
await claim(deps, run, { background: true });
|
|
94
|
+
runs = [run];
|
|
95
|
+
} else {
|
|
96
|
+
const all = listRuns().runs;
|
|
97
|
+
const me = currentSession(deps);
|
|
98
|
+
const owned = me ? all.filter((r) => runOwner(r)?.sessionId === me.sessionId) : [];
|
|
99
|
+
runs = owned.length ? owned : all.slice(0, 1);
|
|
100
|
+
}
|
|
101
|
+
const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
|
|
102
|
+
return { runs: runs.map((r) => peekRun(deps, r, i.name)), hints };
|
|
103
|
+
}
|