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,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { dispatchHints } from "../domain/hints.ts";
|
|
4
5
|
import { ACCESS, type RunRecord } from "../domain/record.ts";
|
|
5
6
|
import { ROLES } from "../domain/roles.ts";
|
|
6
7
|
import { dispatchPaths, readExit, supervisorAlive } from "../infra/dispatch-dir.ts";
|
|
@@ -31,6 +32,8 @@ const AdmitSchema = z.looseObject({
|
|
|
31
32
|
repo: z.string(),
|
|
32
33
|
/** `git status` fingerprints when admitted, for changedOwned and violations */
|
|
33
34
|
before: z.record(z.string(), z.string()),
|
|
35
|
+
/** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
|
|
36
|
+
sessionId: z.string().optional(),
|
|
34
37
|
});
|
|
35
38
|
export type Admit = z.infer<typeof AdmitSchema>;
|
|
36
39
|
|
|
@@ -143,3 +146,29 @@ export function latestDispatch(run: Run, name: string): Dispatch | null {
|
|
|
143
146
|
}
|
|
144
147
|
return all.find((d) => d.admit.dispatchId === id) ?? all.at(-1) ?? null;
|
|
145
148
|
}
|
|
149
|
+
|
|
150
|
+
/** failover.json: what failover did for a limited dispatch (plan 10), written once, under its lock. */
|
|
151
|
+
const FailoverSchema = z.looseObject({
|
|
152
|
+
schema: z.literal(1),
|
|
153
|
+
at: z.string(),
|
|
154
|
+
/** the stand-in it launched, if any */
|
|
155
|
+
standIn: z.object({ dispatchId: z.string(), rung: z.string() }).nullable(),
|
|
156
|
+
/** the limited record's hints, as failover left them ("limit: … failed over to …", "failover: …") */
|
|
157
|
+
hints: z.array(z.string()),
|
|
158
|
+
/** the pause it wrote to state.md, if any */
|
|
159
|
+
pause: z.string().nullable(),
|
|
160
|
+
});
|
|
161
|
+
export type FailoverFile = z.infer<typeof FailoverSchema>;
|
|
162
|
+
|
|
163
|
+
export function readFailover(dir: string): FailoverFile | null {
|
|
164
|
+
try {
|
|
165
|
+
return readVersioned(dispatchPaths(dir).failover, FailoverSchema, 1);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Spec §4.4: what to do next about one finished record; for a usage limit, the failover's own hints. */
|
|
172
|
+
export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
|
|
173
|
+
return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
|
|
174
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { realTmpdir } from "../adapters/access.ts";
|
|
2
|
+
import type { AccessShell } from "../adapters/backend.ts";
|
|
3
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
4
|
+
import "../adapters/all.ts";
|
|
5
|
+
import { tryParseRung } from "../domain/ids.ts";
|
|
6
|
+
import type { Profile } from "../domain/profile.ts";
|
|
7
|
+
import { ROLES } from "../domain/roles.ts";
|
|
8
|
+
import { locksDir } from "../infra/paths.ts";
|
|
9
|
+
import { ensurePrivateDir } from "../infra/store.ts";
|
|
10
|
+
import type { Check } from "./doctor-checks.ts";
|
|
11
|
+
|
|
12
|
+
// Spec §5 and §12: the five access probes doctor runs per backend that serves a workspace-write role, each
|
|
13
|
+
// in that backend's worker shell with the grants a worker gets, and the rows that report them.
|
|
14
|
+
|
|
15
|
+
export type ProbeId = "lock" | "temp" | "loopback" | "https" | "docker";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the probes reach: npm's ping, through HTTPS_PROXY when it is set (Bun's fetch honours it), and
|
|
19
|
+
* `docker`. CATHERD_PROBE_URL and CATHERD_PROBE_DOCKER replace them (tests: a local server, no docker).
|
|
20
|
+
*/
|
|
21
|
+
export const probeTargets = {
|
|
22
|
+
get url(): string {
|
|
23
|
+
return process.env.CATHERD_PROBE_URL || "https://registry.npmjs.org/-/ping";
|
|
24
|
+
},
|
|
25
|
+
get docker(): string {
|
|
26
|
+
return process.env.CATHERD_PROBE_DOCKER || "docker";
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface ProbeDef {
|
|
31
|
+
id: ProbeId;
|
|
32
|
+
label: string;
|
|
33
|
+
/** off when the role's network is off (Docker access goes with the network) */
|
|
34
|
+
network: boolean;
|
|
35
|
+
script: string;
|
|
36
|
+
args: () => string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// every path and URL goes in as an argument, never into the script text
|
|
40
|
+
const WRITE = 'f="$1/.catherd-doctor-$$" && touch "$f" && rm -f "$f"';
|
|
41
|
+
const BUN_EVAL = '"$1" -e "$2"';
|
|
42
|
+
const BIND = 'Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }).stop(true)';
|
|
43
|
+
// npm's ping answers 200; any 4xx or 5xx fails, so a proxy's 407 or 403 for a denied host is not a false green
|
|
44
|
+
// a fetch that throws (no route, DNS, refused) prints its error code, not Bun's stack and version trailer
|
|
45
|
+
const FETCH =
|
|
46
|
+
"let r; try { r = await fetch(process.argv[1], { method: 'HEAD' }) } catch (e) { console.error([e?.code, e?.message].filter(Boolean).join(': ') || String(e)); process.exit(1) } if (r.status >= 400) { console.error(`HTTP ${r.status}${r.status === 407 ? ' (proxy authentication required)' : r.status === 403 ? ' (refused: a proxy denying the host?)' : ''}`); process.exit(1) }";
|
|
47
|
+
|
|
48
|
+
/** Why a probe failed: its last non-empty stderr line (Bun's version trailer skipped), else stdout's. */
|
|
49
|
+
export function lastErrorLine(err: string, out: string): string | undefined {
|
|
50
|
+
const lines = (t: string) =>
|
|
51
|
+
t
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((l) => l.trim())
|
|
54
|
+
.filter((l) => l && !/^Bun v\d/.test(l));
|
|
55
|
+
return lines(err).at(-1) ?? lines(out).at(-1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PROBES: ProbeDef[] = [
|
|
59
|
+
{ id: "lock", label: "lock-dir write", network: false, script: WRITE, args: () => [locksDir()] },
|
|
60
|
+
{ id: "temp", label: "temp write", network: false, script: WRITE, args: () => [realTmpdir()] },
|
|
61
|
+
{
|
|
62
|
+
id: "loopback",
|
|
63
|
+
label: "loopback bind",
|
|
64
|
+
network: true,
|
|
65
|
+
script: BUN_EVAL,
|
|
66
|
+
args: () => [process.execPath, BIND],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "https",
|
|
70
|
+
label: "outbound HTTPS",
|
|
71
|
+
network: true,
|
|
72
|
+
script: `${BUN_EVAL} "$3"`,
|
|
73
|
+
args: () => [process.execPath, FETCH, probeTargets.url],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "docker",
|
|
77
|
+
label: "docker version",
|
|
78
|
+
network: true,
|
|
79
|
+
script: '"$1" version',
|
|
80
|
+
args: () => [probeTargets.docker],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export interface ProbeResult {
|
|
85
|
+
id: ProbeId;
|
|
86
|
+
label: string;
|
|
87
|
+
/** "off": the role's network is off; "skip": docker is not installed */
|
|
88
|
+
state: "ok" | "failed" | "off" | "skip";
|
|
89
|
+
/** the last line the probe printed on failure */
|
|
90
|
+
why?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Runs the five probes in `shell`; the network three (docker among them) only when granted, docker only when installed. */
|
|
94
|
+
export async function runProbes(shell: AccessShell, network: boolean): Promise<ProbeResult[]> {
|
|
95
|
+
const out: ProbeResult[] = [];
|
|
96
|
+
let noLocks: string | null = null;
|
|
97
|
+
try {
|
|
98
|
+
ensurePrivateDir(locksDir());
|
|
99
|
+
} catch (e) {
|
|
100
|
+
noLocks = `cannot create ${locksDir()}: ${e instanceof Error ? e.message : String(e)}`;
|
|
101
|
+
}
|
|
102
|
+
for (const p of PROBES) {
|
|
103
|
+
if (p.id === "lock" && noLocks) {
|
|
104
|
+
out.push({ id: p.id, label: p.label, state: "failed", why: noLocks });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (p.network && !network) {
|
|
108
|
+
out.push({ id: p.id, label: p.label, state: "off" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (p.id === "docker" && !Bun.which(probeTargets.docker, { PATH: process.env.PATH ?? "" })) {
|
|
112
|
+
out.push({ id: p.id, label: p.label, state: "skip" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const r = await shell.run(p.script, p.args());
|
|
116
|
+
const last = lastErrorLine(r?.err ?? "", r?.out ?? "");
|
|
117
|
+
const late = last && / timed out after (\d+) ms$/.exec(last);
|
|
118
|
+
const why =
|
|
119
|
+
r === null
|
|
120
|
+
? "the probe shell did not start"
|
|
121
|
+
: late
|
|
122
|
+
? `timed out after ${Number(late[1]) / 1000} s`
|
|
123
|
+
: last;
|
|
124
|
+
out.push({
|
|
125
|
+
id: p.id,
|
|
126
|
+
label: p.label,
|
|
127
|
+
state: r?.ok ? "ok" : "failed",
|
|
128
|
+
...(r?.ok ? {} : { why: why || "no output" }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The fix for a failed probe, per backend: Codex's sandbox holds catherd's grants; the others run unsandboxed. */
|
|
135
|
+
function fixFor(backend: string, id: ProbeId): string {
|
|
136
|
+
if (backend === "codex") {
|
|
137
|
+
const override =
|
|
138
|
+
"catherd passes this grant to Codex itself (with your top-level [sandbox_workspace_write] writable_roots added): check that no --profile or managed requirements.toml overrides it";
|
|
139
|
+
if (id === "docker")
|
|
140
|
+
return "the Codex sandbox cannot reach the Docker socket here: run the Docker checks in the verifier (full access), or give the role full access";
|
|
141
|
+
return override;
|
|
142
|
+
}
|
|
143
|
+
const FIX: Record<ProbeId, string> = {
|
|
144
|
+
lock: `chmod -R u+w ${locksDir()}`,
|
|
145
|
+
temp: `check that ${realTmpdir()} is writable`,
|
|
146
|
+
loopback: "something on this machine refuses a bind to 127.0.0.1: check the firewall",
|
|
147
|
+
https: "check the network, or set HTTPS_PROXY for this shell",
|
|
148
|
+
docker: `start Docker: ${probeTargets.docker} version fails`,
|
|
149
|
+
};
|
|
150
|
+
return FIX[id];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every backend an enabled workspace-write role runs on or fails over to, with whether any of those roles has network. */
|
|
154
|
+
export function workspaceWriteNetwork(profiles: Profile[]): Map<string, boolean> {
|
|
155
|
+
const out = new Map<string, boolean>();
|
|
156
|
+
for (const p of profiles)
|
|
157
|
+
for (const role of ROLES) {
|
|
158
|
+
const rc = p.roles[role];
|
|
159
|
+
if (!rc.enabled || rc.access !== "workspace-write") continue;
|
|
160
|
+
for (const r of [...rc.rungs, ...rc.rungs.flatMap((x) => p.failover[x] ?? [])]) {
|
|
161
|
+
const b = tryParseRung(r)?.backend;
|
|
162
|
+
if (b && b !== "claude") out.set(b, (out.get(b) ?? false) || rc.network !== false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The `sandbox:codex` row (which `codex sandbox` form runs) and one `access:<backend>` row per backend; a
|
|
170
|
+
* backend not in `installed` (its CLI is not on PATH) is skipped without running a probe.
|
|
171
|
+
*/
|
|
172
|
+
export async function accessChecks(profiles: Profile[], installed: ReadonlySet<string>): Promise<Check[]> {
|
|
173
|
+
const checks: Check[] = [];
|
|
174
|
+
for (const [id, network] of workspaceWriteNetwork(profiles)) {
|
|
175
|
+
const a = adapterFor(id);
|
|
176
|
+
if (!a?.accessShell) continue;
|
|
177
|
+
if (!installed.has(id)) {
|
|
178
|
+
checks.push({
|
|
179
|
+
id: `access:${id}`,
|
|
180
|
+
label: `${id} worker access`,
|
|
181
|
+
state: "skip",
|
|
182
|
+
word: "not installed",
|
|
183
|
+
detail: `${id} is not installed: no worker runs on it here`,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const shell = await a.accessShell({ network }).catch((e: unknown) => String(e));
|
|
188
|
+
if (id === "codex")
|
|
189
|
+
checks.push(
|
|
190
|
+
typeof shell === "string"
|
|
191
|
+
? { id: "sandbox:codex", label: "codex sandbox", state: "skip", word: "not tested", detail: shell }
|
|
192
|
+
: { id: "sandbox:codex", label: "codex sandbox", state: "ok", word: "ready", detail: shell.how },
|
|
193
|
+
);
|
|
194
|
+
const base = { id: `access:${id}`, label: `${id} worker access` };
|
|
195
|
+
if (typeof shell === "string") {
|
|
196
|
+
checks.push({ ...base, state: "skip", word: "not tested", detail: shell });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let results: ProbeResult[];
|
|
200
|
+
try {
|
|
201
|
+
results = await runProbes(shell, network);
|
|
202
|
+
} finally {
|
|
203
|
+
shell.close();
|
|
204
|
+
}
|
|
205
|
+
const failed = results.filter((r) => r.state === "failed");
|
|
206
|
+
const passed = results.filter((r) => r.state === "ok").map((r) => r.label);
|
|
207
|
+
const notes = [
|
|
208
|
+
...(results.some((r) => r.state === "off") ? ["network off by profile"] : []),
|
|
209
|
+
...(results.some((r) => r.state === "skip") ? ["no docker"] : []),
|
|
210
|
+
// only Codex's sandbox can take the network away; the others' shells keep it
|
|
211
|
+
...(id !== "codex" && !network ? [`network: false is not enforced by ${id}'s shell`] : []),
|
|
212
|
+
];
|
|
213
|
+
checks.push(
|
|
214
|
+
failed.length
|
|
215
|
+
? {
|
|
216
|
+
...base,
|
|
217
|
+
state: "warn",
|
|
218
|
+
word: "blocked",
|
|
219
|
+
detail: `in ${shell.how}, a worker cannot: ${failed.map((r) => `${r.label} (${r.why})`).join("; ")}`,
|
|
220
|
+
fix: failed.map((r) => `${r.label}: ${fixFor(id, r.id)}`).join("\n"),
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
...base,
|
|
224
|
+
state: "ok",
|
|
225
|
+
word: "ready",
|
|
226
|
+
detail: [`${passed.join(", ")} in ${shell.how}`, ...notes].join(" · "),
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return checks;
|
|
231
|
+
}
|
|
@@ -26,24 +26,6 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
|
|
|
26
26
|
return used;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Spec §10.3: the backends that run an enabled workspace-write role, or stand in for one of its rungs,
|
|
31
|
-
* whose sandbox must let such a worker take the heavy lock.
|
|
32
|
-
*/
|
|
33
|
-
export function workspaceWriteBackends(profiles: Profile[]): Set<string> {
|
|
34
|
-
const out = new Set<string>();
|
|
35
|
-
for (const p of profiles)
|
|
36
|
-
for (const role of ROLES) {
|
|
37
|
-
const rc = p.roles[role];
|
|
38
|
-
if (!rc.enabled || rc.access !== "workspace-write") continue;
|
|
39
|
-
for (const r of [...rc.rungs, ...rc.rungs.flatMap((x) => p.failover[x] ?? [])]) {
|
|
40
|
-
const b = backendOf(r);
|
|
41
|
-
if (b) out.add(b);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
29
|
const PROBLEM_WORD: Record<string, string> = {
|
|
48
30
|
E_BACKEND_MISSING: "missing",
|
|
49
31
|
E_BACKEND_TOO_OLD: "too old",
|
|
@@ -98,9 +80,11 @@ export function moveRolesFix(install: string, id: string, to: string, profiles:
|
|
|
98
80
|
].join("\n");
|
|
99
81
|
}
|
|
100
82
|
|
|
83
|
+
/** One row per backend; `installed`, when given, collects the backends whose CLI is on PATH. */
|
|
101
84
|
export async function backendChecks(
|
|
102
85
|
used: Map<string, "role" | "failover">,
|
|
103
86
|
profiles: Profile[],
|
|
87
|
+
installed?: Set<string>,
|
|
104
88
|
): Promise<Check[]> {
|
|
105
89
|
const checks: Check[] = [];
|
|
106
90
|
const ready: string[] = [];
|
|
@@ -116,6 +100,7 @@ export async function backendChecks(
|
|
|
116
100
|
{ code: "E_IO_UNEXPECTED" as const, message: errText(e), fix: `run ${id} --version to see why` },
|
|
117
101
|
],
|
|
118
102
|
}));
|
|
103
|
+
if (probe.installed) installed?.add(id);
|
|
119
104
|
const problem = probe.problems[0];
|
|
120
105
|
const use = used.get(id);
|
|
121
106
|
if (!problem) {
|
|
@@ -4,11 +4,13 @@ import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
|
4
4
|
import { claudeHome, locksDir } from "../infra/paths.ts";
|
|
5
5
|
import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
|
|
6
6
|
import { agentLinkState } from "./agent-links.ts";
|
|
7
|
+
import type { Handshake } from "./doctor.ts";
|
|
7
8
|
import { activeName } from "./profile-store.ts";
|
|
8
9
|
|
|
9
10
|
// The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
/** `info`: worth knowing, nothing to fix (spec 1.1 §13: the shipped defaults' access) */
|
|
13
|
+
type CheckState = "ok" | "warn" | "fail" | "skip" | "info";
|
|
12
14
|
|
|
13
15
|
/** One row of `catherd doctor` (spec §10.3): its state, one word, the detail and the full fix. */
|
|
14
16
|
export interface Check {
|
|
@@ -66,6 +68,49 @@ export function pluginCheck(version: string): Check {
|
|
|
66
68
|
return { ...base, state: "ok", word: "ready", detail: installed };
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/** What makes the plugin's launcher run this catherd without a resolve: a global install at this version. */
|
|
72
|
+
export const reinstallCommand = (version: string): string => `bun add -g catherd-cli@${version}`;
|
|
73
|
+
|
|
74
|
+
const MISSING_MODULE = /Cannot find (?:module|package) ['"]?([^'"\s]+)/;
|
|
75
|
+
const NO_BUNX = /bunx: (?:command )?not found/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spec 1.1 §12: the `mcp` row. The handshake starts the server the way the plugin does; a module it cannot
|
|
79
|
+
* load (a half-cleaned bunx cache, a broken global install) and a launcher that finds neither catherd nor
|
|
80
|
+
* bunx both say "reinstall: <command>".
|
|
81
|
+
*/
|
|
82
|
+
export function mcpCheck(h: Handshake, version: string): Check {
|
|
83
|
+
const base = { id: "mcp", label: "MCP server" };
|
|
84
|
+
if (h.ok && h.tools.includes("status"))
|
|
85
|
+
return { ...base, state: "ok", word: "ready", detail: `answers tools/list with ${h.tools.length} tools` };
|
|
86
|
+
const said = `${h.error ?? ""}\n${h.stderr ?? ""}`;
|
|
87
|
+
const reinstall = reinstallCommand(version);
|
|
88
|
+
const missing = MISSING_MODULE.exec(said)?.[1];
|
|
89
|
+
if (missing)
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
state: "fail",
|
|
93
|
+
word: "broken install",
|
|
94
|
+
detail: `cannot load ${missing}; reinstall: ${reinstall}`,
|
|
95
|
+
fix: reinstall,
|
|
96
|
+
};
|
|
97
|
+
if (NO_BUNX.test(said))
|
|
98
|
+
return {
|
|
99
|
+
...base,
|
|
100
|
+
state: "fail",
|
|
101
|
+
word: "missing",
|
|
102
|
+
detail: `no catherd ${version} on PATH and no bunx to fetch it; reinstall: ${reinstall}`,
|
|
103
|
+
fix: reinstall,
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
state: "fail",
|
|
108
|
+
word: "no answer",
|
|
109
|
+
detail: h.error ?? "tools/list has no status tool",
|
|
110
|
+
fix: "run catherd mcp to see why it does not start",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
export function locksCheck(): Check {
|
|
70
115
|
const base = { id: "locks", label: "heavy-lock dir" };
|
|
71
116
|
try {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { envelope } from "../domain/notice.ts";
|
|
4
|
+
import { readSessionEnv, sessionFileFor } from "../infra/claude-session.ts";
|
|
5
|
+
import { claudeHome } from "../infra/paths.ts";
|
|
6
|
+
import { sendToInbox } from "../infra/peer-inbox.ts";
|
|
7
|
+
import type { Check } from "./doctor-checks.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spec §3.9 `doctor`'s `push` row: run from inside a Claude Code session (its Bash tool), send that session one
|
|
11
|
+
* `later` message and look for the enqueue line its transcript gets when the message is accepted
|
|
12
|
+
* (docs/research/2026-09-28-cross-session-messaging.md §7).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PushOutcome = "ok" | "no-session" | "held" | "unconfirmed" | "failed";
|
|
16
|
+
|
|
17
|
+
export interface PushProbe {
|
|
18
|
+
outcome: PushOutcome;
|
|
19
|
+
detail: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How long the probe looks for the enqueue line; tests shorten it. */
|
|
23
|
+
export const pushLimits = { waitMs: 5_000, pollMs: 100 };
|
|
24
|
+
|
|
25
|
+
/** The session's transcript, found by globbing `projects/*` (a git worktree logs under its main checkout's slug). */
|
|
26
|
+
function transcriptOf(sessionId: string): string | null {
|
|
27
|
+
const projects = join(claudeHome(), "projects");
|
|
28
|
+
if (!existsSync(projects)) return null;
|
|
29
|
+
for (const d of readdirSync(projects)) {
|
|
30
|
+
const f = join(projects, d, `${sessionId}.jsonl`);
|
|
31
|
+
if (existsSync(f)) return f;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const enqueued = (file: string, nonce: string): boolean => {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(file, "utf8")
|
|
39
|
+
.split("\n")
|
|
40
|
+
.some((l) => l.includes('"operation":"enqueue"') && l.includes(nonce));
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function probePush(env: Record<string, string | undefined> = process.env): Promise<PushProbe> {
|
|
47
|
+
const s = readSessionEnv(env);
|
|
48
|
+
if (!s?.socketPath)
|
|
49
|
+
return { outcome: "no-session", detail: "run catherd doctor from a Claude Code session to test push" };
|
|
50
|
+
const sessionId = sessionFileFor(s)?.sessionId ?? s.sessionId;
|
|
51
|
+
const nonce = crypto.randomUUID().slice(0, 8);
|
|
52
|
+
const r = await sendToInbox(s, envelope(`catherd doctor: push test ${nonce}, no action needed`), "later");
|
|
53
|
+
// a socket that is gone or refuses is a shell that outlived its session, not a protocol change
|
|
54
|
+
if (r.outcome === "no-session" || r.outcome === "refused")
|
|
55
|
+
return {
|
|
56
|
+
outcome: "no-session",
|
|
57
|
+
detail: `this session's inbox is not reachable (${r.reason ?? r.outcome}); run catherd doctor from a live Claude Code session to test push`,
|
|
58
|
+
};
|
|
59
|
+
if (r.outcome !== "sent")
|
|
60
|
+
return {
|
|
61
|
+
outcome: "failed",
|
|
62
|
+
detail: `catherd cannot notify this Claude Code version; peek still works (${r.reason ?? r.outcome})`,
|
|
63
|
+
};
|
|
64
|
+
const deadline = Date.now() + pushLimits.waitMs;
|
|
65
|
+
let file: string | null = null;
|
|
66
|
+
for (;;) {
|
|
67
|
+
file = sessionId ? transcriptOf(sessionId) : null;
|
|
68
|
+
if (file && enqueued(file, nonce))
|
|
69
|
+
return { outcome: "ok", detail: "a test message reached this session" };
|
|
70
|
+
if (Date.now() > deadline) break;
|
|
71
|
+
await Bun.sleep(pushLimits.pollMs);
|
|
72
|
+
}
|
|
73
|
+
return file
|
|
74
|
+
? {
|
|
75
|
+
outcome: "held",
|
|
76
|
+
detail: `the test message was sent but not accepted within ${pushLimits.waitMs / 1000} s: a crossSessionInbound setting holds or refuses it`,
|
|
77
|
+
}
|
|
78
|
+
: {
|
|
79
|
+
outcome: "unconfirmed",
|
|
80
|
+
detail: "sent; no transcript of this session was found to confirm it arrived",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The probe as a `doctor` row. */
|
|
85
|
+
export function pushCheck(p: PushProbe): Check {
|
|
86
|
+
const base = { id: "push", label: "push to Claude Code", detail: p.detail };
|
|
87
|
+
switch (p.outcome) {
|
|
88
|
+
case "ok":
|
|
89
|
+
return { ...base, state: "ok", word: "ready" };
|
|
90
|
+
case "no-session":
|
|
91
|
+
return { ...base, state: "skip", word: "no session" };
|
|
92
|
+
case "held":
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
state: "warn",
|
|
96
|
+
word: "held",
|
|
97
|
+
fix: `set "crossSessionInbound": "accept" in ${join(claudeHome(), "settings.json")}, or remove a project setting that sets it to hold or refuse`,
|
|
98
|
+
};
|
|
99
|
+
case "unconfirmed":
|
|
100
|
+
return { ...base, state: "warn", word: "not confirmed" };
|
|
101
|
+
case "failed":
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
state: "fail",
|
|
105
|
+
word: "failed",
|
|
106
|
+
fix: "update catherd (and Claude Code); meanwhile peek(run) shows each role and each unread record",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|