catherd-cli 1.3.0 → 1.4.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 +75 -36
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +44 -38
- package/plugin/skills/catherd-setup/SKILL.md +22 -6
- package/src/adapters/access.ts +7 -1
- package/src/adapters/cli.ts +1 -64
- package/src/cli.ts +48 -19
- package/src/domain/host.ts +17 -0
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +32 -5
- package/src/domain/record.ts +1 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +19 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +2 -2
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +7 -3
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +6 -1
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +105 -33
- package/src/services/finalize.ts +1 -1
- package/src/services/lane-service.ts +1 -0
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +116 -30
- package/src/services/profile-store.ts +23 -6
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +2 -0
- package/src/services/run-store.ts +10 -4
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import type { Probe } from "../adapters/backend.ts";
|
|
2
3
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
4
|
import "../adapters/all.ts";
|
|
4
5
|
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
5
6
|
import type { Profile } from "../domain/profile.ts";
|
|
6
7
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
7
|
-
import { probeBackend } from "./backends.ts";
|
|
8
|
+
import { standInFor, probeBackend } from "./backends.ts";
|
|
8
9
|
import { refreshDiscovery } from "./catalog-service.ts";
|
|
9
|
-
import { type Check, errText, fixOf } from "./doctor-checks.ts";
|
|
10
|
+
import { type Check, errText, fixOf, hostFlag } from "./doctor-checks.ts";
|
|
10
11
|
|
|
11
12
|
// The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
|
|
12
13
|
|
|
@@ -22,7 +23,12 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
|
|
|
22
23
|
};
|
|
23
24
|
for (const p of profiles) {
|
|
24
25
|
for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
|
|
25
|
-
for (const
|
|
26
|
+
for (const role of ROLES)
|
|
27
|
+
if (p.roles[role].enabled)
|
|
28
|
+
for (const r of p.roles[role].rungs) {
|
|
29
|
+
const to = standInFor(p.failover, r);
|
|
30
|
+
if (to) note(to, "failover");
|
|
31
|
+
}
|
|
26
32
|
}
|
|
27
33
|
return used;
|
|
28
34
|
}
|
|
@@ -52,10 +58,16 @@ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.
|
|
|
52
58
|
* each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
|
|
53
59
|
* off Codex it is turned off instead.
|
|
54
60
|
*/
|
|
55
|
-
export function moveRolesFix(
|
|
61
|
+
export function moveRolesFix(
|
|
62
|
+
install: string,
|
|
63
|
+
id: string,
|
|
64
|
+
to: string,
|
|
65
|
+
profiles: Profile[],
|
|
66
|
+
host: OrchestrationHost = "unknown",
|
|
67
|
+
): string {
|
|
56
68
|
const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
|
|
57
69
|
const set = (p: Profile, path: string, v: string) =>
|
|
58
|
-
`catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
|
|
70
|
+
`catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}${hostFlag(host)}`;
|
|
59
71
|
const stuck = new Set<Role>();
|
|
60
72
|
const commands: string[] = [];
|
|
61
73
|
for (const p of profiles)
|
|
@@ -87,10 +99,21 @@ export async function backendChecks(
|
|
|
87
99
|
used: Map<string, "role" | "failover">,
|
|
88
100
|
profiles: Profile[],
|
|
89
101
|
installed?: Set<string>,
|
|
102
|
+
host: OrchestrationHost = "unknown",
|
|
90
103
|
): Promise<Check[]> {
|
|
91
104
|
const checks: Check[] = [];
|
|
92
105
|
const ready: string[] = [];
|
|
93
106
|
for (const id of ADAPTER_IDS) {
|
|
107
|
+
if (host === "codex" && id === "claude-code" && !used.has(id)) {
|
|
108
|
+
checks.push({
|
|
109
|
+
id: `backend:${id}`,
|
|
110
|
+
label: id,
|
|
111
|
+
state: "skip",
|
|
112
|
+
word: "not required",
|
|
113
|
+
detail: "not probed: the selected profile has no headless Claude role or reachable failover",
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
94
117
|
const a = adapterFor(id);
|
|
95
118
|
if (!a) continue;
|
|
96
119
|
const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
|
|
@@ -124,7 +147,7 @@ export async function backendChecks(
|
|
|
124
147
|
state: "warn",
|
|
125
148
|
word: "billing",
|
|
126
149
|
detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
|
|
127
|
-
fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
|
|
150
|
+
fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}${hostFlag(host)}`,
|
|
128
151
|
}
|
|
129
152
|
: { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
|
|
130
153
|
);
|
|
@@ -147,7 +170,7 @@ export async function backendChecks(
|
|
|
147
170
|
for (const c of checks) {
|
|
148
171
|
const id = c.id.slice("backend:".length);
|
|
149
172
|
if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
|
|
150
|
-
c.fix = moveRolesFix(c.fix, id, to, profiles);
|
|
173
|
+
c.fix = moveRolesFix(c.fix, id, to, profiles, host);
|
|
151
174
|
}
|
|
152
175
|
// spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
|
|
153
176
|
let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
@@ -131,9 +132,13 @@ export function locksCheck(): Check {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
export function agentsCheck(
|
|
135
|
+
export function agentsCheck(
|
|
136
|
+
host: OrchestrationHost,
|
|
137
|
+
selected?: string[],
|
|
138
|
+
relink = `catherd profile use ${activeName()}`,
|
|
139
|
+
): Check {
|
|
135
140
|
const base = { id: "agents", label: "Claude agents" };
|
|
136
|
-
const links = agentLinkState();
|
|
141
|
+
const links = agentLinkState(host, selected);
|
|
137
142
|
const broken = [...links.missing, ...links.stale];
|
|
138
143
|
if (broken.length)
|
|
139
144
|
return {
|
|
@@ -141,13 +146,19 @@ export function agentsCheck(): Check {
|
|
|
141
146
|
state: "fail",
|
|
142
147
|
word: links.missing.length ? "missing" : "stale",
|
|
143
148
|
detail: broken.join(", "),
|
|
144
|
-
fix:
|
|
149
|
+
fix: relink,
|
|
145
150
|
};
|
|
146
151
|
return links.ok.length
|
|
147
152
|
? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
|
|
148
153
|
: { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* ` --host <host>` for a fix doctor prints, when it judged on a known host: run from a plain terminal, the fix
|
|
158
|
+
* must judge the profile the same way (omitted architect/verifier rungs resolve per host).
|
|
159
|
+
*/
|
|
160
|
+
export const hostFlag = (host: OrchestrationHost): string => (host === "unknown" ? "" : ` --host ${host}`);
|
|
161
|
+
|
|
151
162
|
/** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
|
|
152
163
|
const UI_BROWSER = "agent-browser";
|
|
153
164
|
|
|
@@ -155,7 +166,11 @@ const UI_BROWSER = "agent-browser";
|
|
|
155
166
|
* The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
|
|
156
167
|
* dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
|
|
157
168
|
*/
|
|
158
|
-
export function uiBrowserCheck(
|
|
169
|
+
export function uiBrowserCheck(
|
|
170
|
+
profiles: Profile[],
|
|
171
|
+
onPath: (bin: string) => string | null,
|
|
172
|
+
host: OrchestrationHost = "unknown",
|
|
173
|
+
): Check | null {
|
|
159
174
|
const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
|
|
160
175
|
if (!on.length || onPath(UI_BROWSER)) return null;
|
|
161
176
|
return {
|
|
@@ -167,7 +182,7 @@ export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => str
|
|
|
167
182
|
fix: [
|
|
168
183
|
`npm i -g ${UI_BROWSER}`,
|
|
169
184
|
"or turn the role off:",
|
|
170
|
-
...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}`),
|
|
185
|
+
...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}${hostFlag(host)}`),
|
|
171
186
|
].join("\n"),
|
|
172
187
|
};
|
|
173
188
|
}
|
|
@@ -1,109 +1,88 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import type { HostContext } from "../domain/host.ts";
|
|
3
2
|
import { envelope } from "../domain/notice.ts";
|
|
4
3
|
import { readSessionEnv, sessionFileFor } from "../infra/claude-session.ts";
|
|
5
|
-
import {
|
|
4
|
+
import { sendToCodexQueue } from "../infra/codex-queue.ts";
|
|
6
5
|
import { sendToInbox } from "../infra/peer-inbox.ts";
|
|
7
6
|
import type { Check } from "./doctor-checks.ts";
|
|
8
7
|
|
|
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
|
-
|
|
8
|
+
export type PushOutcome = "ok" | "no-session" | "unconfirmed" | "failed";
|
|
17
9
|
export interface PushProbe {
|
|
18
10
|
outcome: PushOutcome;
|
|
19
11
|
detail: string;
|
|
12
|
+
enqueue: "accepted" | "ambiguous" | "not-submitted" | "no-session";
|
|
13
|
+
processing: "observed" | "unconfirmed";
|
|
14
|
+
msgId: string | null;
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
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")
|
|
17
|
+
/** Explicit smoke only: native receipts prove acceptance, never model processing or result collection. */
|
|
18
|
+
export async function probePush(
|
|
19
|
+
host: HostContext,
|
|
20
|
+
env: Record<string, string | undefined>,
|
|
21
|
+
): Promise<PushProbe> {
|
|
22
|
+
const target = host.session;
|
|
23
|
+
if (host.conflict || !target || host.host === "unknown" || target.host !== host.host)
|
|
55
24
|
return {
|
|
56
25
|
outcome: "no-session",
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
detail: `catherd cannot notify this Claude Code version; peek still works (${r.reason ?? r.outcome})`,
|
|
26
|
+
enqueue: "no-session",
|
|
27
|
+
processing: "unconfirmed",
|
|
28
|
+
msgId: null,
|
|
29
|
+
detail:
|
|
30
|
+
"No validated live host session; run doctor --test-push from the original conversation. Terminal --host selects checks only. Use peek/result.",
|
|
63
31
|
};
|
|
64
|
-
const
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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":
|
|
32
|
+
const content = envelope(`catherd doctor: push test ${crypto.randomUUID().slice(0, 8)}, no action needed`);
|
|
33
|
+
let enqueue: PushProbe["enqueue"];
|
|
34
|
+
let msgId: string | null = null;
|
|
35
|
+
if (host.host === "codex") {
|
|
36
|
+
const sent = await sendToCodexQueue(target, content, env);
|
|
37
|
+
enqueue = sent.outcome;
|
|
38
|
+
if (sent.outcome === "accepted") msgId = sent.msgId;
|
|
39
|
+
} else {
|
|
40
|
+
const s = readSessionEnv(env);
|
|
41
|
+
const liveId = s ? (sessionFileFor(s)?.sessionId ?? s.sessionId) : null;
|
|
42
|
+
if (!s || liveId !== target.sessionId)
|
|
93
43
|
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
44
|
+
outcome: "no-session",
|
|
45
|
+
enqueue: "no-session",
|
|
46
|
+
processing: "unconfirmed",
|
|
47
|
+
msgId: null,
|
|
48
|
+
detail: "Claude inbox identity does not match the validated live session; use peek/result.",
|
|
98
49
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
case "failed":
|
|
50
|
+
const sent = await sendToInbox(s, content, "later");
|
|
51
|
+
if (sent.outcome === "no-session" || sent.outcome === "refused")
|
|
102
52
|
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
53
|
+
outcome: "no-session",
|
|
54
|
+
enqueue: "not-submitted",
|
|
55
|
+
processing: "unconfirmed",
|
|
56
|
+
msgId: null,
|
|
57
|
+
detail:
|
|
58
|
+
"This session's inbox is not reachable; run doctor --test-push from a live Claude Code session. Use peek/result.",
|
|
107
59
|
};
|
|
60
|
+
enqueue = sent.outcome === "sent" && sent.msgId ? "accepted" : "ambiguous";
|
|
61
|
+
if (enqueue === "accepted") msgId = sent.msgId ?? null;
|
|
108
62
|
}
|
|
63
|
+
return {
|
|
64
|
+
outcome: enqueue === "accepted" ? "ok" : enqueue === "ambiguous" ? "unconfirmed" : "failed",
|
|
65
|
+
enqueue,
|
|
66
|
+
processing: "unconfirmed",
|
|
67
|
+
msgId,
|
|
68
|
+
detail:
|
|
69
|
+
enqueue === "accepted"
|
|
70
|
+
? "Smoke enqueue accepted; processing unconfirmed. Unloaded, interrupted or restarted hosts may retain queued input. Use peek/result for durable unread results."
|
|
71
|
+
: enqueue === "ambiguous"
|
|
72
|
+
? "Smoke has no verified queue receipt; processing unconfirmed. Retrying may duplicate input. Use peek/result."
|
|
73
|
+
: "Smoke was not submitted; check native queue support and the existing endpoint. Use peek/result.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function pushCheck(p: PushProbe): Check {
|
|
78
|
+
const base = { id: "push", label: "host completion push", detail: p.detail };
|
|
79
|
+
if (p.enqueue === "accepted") return { ...base, state: "ok", word: "enqueue accepted" };
|
|
80
|
+
if (p.outcome === "no-session") return { ...base, state: "skip", word: "no session" };
|
|
81
|
+
if (p.enqueue === "ambiguous") return { ...base, state: "warn", word: "ambiguous" };
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
state: "fail",
|
|
85
|
+
word: "not submitted",
|
|
86
|
+
fix: "Check native host transport; peek/result keeps the unread record available.",
|
|
87
|
+
};
|
|
109
88
|
}
|
package/src/services/doctor.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { queueCapability, type QueueCapability } from "../infra/codex-queue.ts";
|
|
2
|
+
import type { HostContext } from "../domain/host.ts";
|
|
1
3
|
import { existsSync, statSync } from "node:fs";
|
|
2
4
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
5
|
import "../adapters/all.ts";
|
|
@@ -8,10 +10,11 @@ import type { JevTransport } from "../infra/jev-client.ts";
|
|
|
8
10
|
import { linkedProfiles } from "./agent-links.ts";
|
|
9
11
|
import { accessChecks } from "./doctor-access.ts";
|
|
10
12
|
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
11
|
-
import { type PushProbe, pushCheck } from "./doctor-push.ts";
|
|
13
|
+
import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
|
|
12
14
|
import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
|
|
13
15
|
import {
|
|
14
16
|
agentsCheck,
|
|
17
|
+
hostFlag,
|
|
15
18
|
type Check,
|
|
16
19
|
errText,
|
|
17
20
|
fixOf,
|
|
@@ -31,9 +34,13 @@ import {
|
|
|
31
34
|
readProjects,
|
|
32
35
|
validateNamed,
|
|
33
36
|
} from "./profile-store.ts";
|
|
37
|
+
import { inspectionHost } from "./run-debug.ts";
|
|
34
38
|
import { listRuns } from "./run-store.ts";
|
|
35
39
|
|
|
36
40
|
export interface DoctorReport {
|
|
41
|
+
host: HostContext;
|
|
42
|
+
queue: QueueCapability | null;
|
|
43
|
+
push: PushProbe | null;
|
|
37
44
|
/** false when any check failed: `catherd doctor` exits 3 */
|
|
38
45
|
ready: boolean;
|
|
39
46
|
version: string;
|
|
@@ -49,13 +56,17 @@ export interface Handshake {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface DoctorDeps {
|
|
59
|
+
host: HostContext;
|
|
60
|
+
repo: string | null;
|
|
52
61
|
bunVersion: string;
|
|
53
62
|
/** the package version, which the Claude Code plugin must pin */
|
|
54
63
|
version: string;
|
|
55
64
|
/** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
|
|
56
65
|
handshake: () => Promise<Handshake>;
|
|
57
|
-
/**
|
|
66
|
+
/** Explicit smoke test injection; default doctor never invokes it. */
|
|
58
67
|
push?: () => Promise<PushProbe>;
|
|
68
|
+
testPush?: boolean;
|
|
69
|
+
env?: Record<string, string | undefined>;
|
|
59
70
|
jev?: JevTransport;
|
|
60
71
|
}
|
|
61
72
|
|
|
@@ -78,13 +89,15 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
78
89
|
: { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
|
|
79
90
|
);
|
|
80
91
|
|
|
92
|
+
// every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
|
|
93
|
+
const onHost = hostFlag(d.host.host);
|
|
81
94
|
let profiles: Profile[] = [];
|
|
82
95
|
let linked: string[] = [];
|
|
83
96
|
let active: Profile | null = null;
|
|
84
97
|
try {
|
|
85
98
|
readConfig();
|
|
86
99
|
readProjects();
|
|
87
|
-
active = getProfile(activeName());
|
|
100
|
+
active = getProfile(activeName(d.repo), d.host.host);
|
|
88
101
|
linked = linkedProfiles();
|
|
89
102
|
checks.push({
|
|
90
103
|
id: "config",
|
|
@@ -106,7 +119,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
106
119
|
// one unreadable linked profile must not hide the others' backends: its own row below reports it
|
|
107
120
|
profiles = linked.flatMap((n) => {
|
|
108
121
|
try {
|
|
109
|
-
return [getProfile(n)];
|
|
122
|
+
return [getProfile(n, d.host.host)];
|
|
110
123
|
} catch {
|
|
111
124
|
return [];
|
|
112
125
|
}
|
|
@@ -118,7 +131,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
118
131
|
checks.push({
|
|
119
132
|
id: `binding:${repo}`,
|
|
120
133
|
label: `binding ${repo}`,
|
|
121
|
-
state: "fail",
|
|
134
|
+
state: repo === d.repo ? "fail" : "warn",
|
|
122
135
|
word: "missing",
|
|
123
136
|
detail: `bound to profile ${name}, which does not exist`,
|
|
124
137
|
fix: `cd ${repo} && catherd profile use --repo --clear`,
|
|
@@ -131,30 +144,32 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
131
144
|
const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
|
|
132
145
|
for (const name of names) {
|
|
133
146
|
const id = name === active?.name ? "profile" : `profile:${name}`;
|
|
134
|
-
const validate = `catherd profile validate ${name}`;
|
|
147
|
+
const validate = `catherd profile validate ${name}${onHost}`;
|
|
135
148
|
const named = (fix: string) =>
|
|
136
|
-
fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
149
|
+
fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
|
|
150
|
+
const row = guarded(id, `profile ${name}`, validate, () => {
|
|
151
|
+
const v = validateNamed(name, d.repo, d.host.host);
|
|
152
|
+
const first = v.errors[0] ?? v.warnings[0];
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
label: `profile ${name}`,
|
|
156
|
+
state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
|
|
157
|
+
word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
|
|
158
|
+
detail: first
|
|
159
|
+
? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
|
|
160
|
+
: "valid",
|
|
161
|
+
...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
if (name !== active?.name && row.state === "fail") row.state = "warn";
|
|
165
|
+
checks.push(row);
|
|
153
166
|
}
|
|
154
167
|
|
|
168
|
+
const diagnostics = profiles;
|
|
169
|
+
profiles = active ? [active] : [];
|
|
155
170
|
const used = usedBackends(profiles);
|
|
156
171
|
const installed = new Set<string>();
|
|
157
|
-
checks.push(...(await backendChecks(used, profiles, installed)));
|
|
172
|
+
checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
|
|
158
173
|
|
|
159
174
|
if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
|
|
160
175
|
checks.push({
|
|
@@ -194,18 +209,44 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
194
209
|
|
|
195
210
|
// spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
|
|
196
211
|
// profiles' stand-ins to confirm
|
|
197
|
-
const mine = [...(active ? [active] : []), ...
|
|
212
|
+
const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
|
|
198
213
|
checks.push(
|
|
199
214
|
guarded("sources", "sources", "catherd catalog sync --force", () =>
|
|
200
215
|
sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
|
|
201
216
|
),
|
|
202
217
|
);
|
|
203
218
|
|
|
204
|
-
|
|
219
|
+
const native = used.get("claude");
|
|
220
|
+
const plugin = pluginCheck(d.version);
|
|
221
|
+
if (d.host.host !== "claude-code" && native !== "role") {
|
|
222
|
+
plugin.state = native === "failover" ? "warn" : "skip";
|
|
223
|
+
plugin.word = native === "failover" ? "optional" : "not required";
|
|
224
|
+
}
|
|
225
|
+
checks.push(plugin);
|
|
205
226
|
|
|
227
|
+
// relinks the profile doctor checked: this repo's binding when it has one, else the active profile
|
|
228
|
+
let bound = false;
|
|
229
|
+
try {
|
|
230
|
+
bound = d.repo !== null && readProjects().bindings[d.repo] === active?.name;
|
|
231
|
+
} catch {
|
|
232
|
+
// the config row above already reports an unreadable projects.json
|
|
233
|
+
}
|
|
234
|
+
const relink = active
|
|
235
|
+
? `${bound ? `cd ${d.repo} && ` : ""}catherd profile use ${active.name}${bound ? " --repo" : ""}${onHost}`
|
|
236
|
+
: "";
|
|
206
237
|
checks.push(
|
|
207
238
|
active
|
|
208
|
-
? guarded("agents", "Claude agents",
|
|
239
|
+
? guarded("agents", "Claude agents", relink, () =>
|
|
240
|
+
native
|
|
241
|
+
? agentsCheck(d.host.host, [active!.name], relink)
|
|
242
|
+
: {
|
|
243
|
+
id: "agents",
|
|
244
|
+
label: "Claude agents",
|
|
245
|
+
state: "skip",
|
|
246
|
+
word: "not required",
|
|
247
|
+
detail: "selected profile has no native Claude roles",
|
|
248
|
+
},
|
|
249
|
+
)
|
|
209
250
|
: {
|
|
210
251
|
id: "agents",
|
|
211
252
|
label: "Claude agents",
|
|
@@ -215,15 +256,39 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
215
256
|
},
|
|
216
257
|
);
|
|
217
258
|
|
|
259
|
+
const agents = checks.find((c) => c.id === "agents");
|
|
260
|
+
if (native === "failover" && agents?.state === "fail") agents.state = "warn";
|
|
261
|
+
|
|
218
262
|
const h = await d
|
|
219
263
|
.handshake()
|
|
220
264
|
.catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
|
|
221
265
|
checks.push(mcpCheck(h, d.version));
|
|
222
266
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
267
|
+
const queue =
|
|
268
|
+
d.host.host === "codex" && !d.host.conflict ? await queueCapability(d.env ?? process.env) : null;
|
|
269
|
+
if (queue)
|
|
270
|
+
checks.push({
|
|
271
|
+
id: "push-capability",
|
|
272
|
+
label: "native Codex queue",
|
|
273
|
+
state:
|
|
274
|
+
!queue.cli || queue.server === "unsupported" ? "fail" : queue.server === "unverified" ? "warn" : "ok",
|
|
275
|
+
word: !queue.cli ? "unavailable" : queue.server,
|
|
276
|
+
detail: queue.reason ?? "Native server advertises queue support",
|
|
277
|
+
...(!queue.cli || queue.server === "unsupported"
|
|
278
|
+
? { fix: "Update native Codex/server; use peek/result while push is unavailable." }
|
|
279
|
+
: {}),
|
|
280
|
+
});
|
|
281
|
+
let push: PushProbe | null = null;
|
|
282
|
+
if (d.testPush) {
|
|
283
|
+
push = await (d.push ?? (() => probePush(d.host, d.env ?? process.env)))().catch((): PushProbe => ({
|
|
284
|
+
outcome: "failed",
|
|
285
|
+
enqueue: "ambiguous",
|
|
286
|
+
processing: "unconfirmed",
|
|
287
|
+
msgId: null,
|
|
288
|
+
detail: "Smoke has no verified receipt; retry may duplicate input. Use peek/result.",
|
|
289
|
+
}));
|
|
290
|
+
checks.push(pushCheck(push));
|
|
291
|
+
}
|
|
227
292
|
|
|
228
293
|
checks.push(locksCheck());
|
|
229
294
|
// spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
|
|
@@ -322,7 +387,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
322
387
|
detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
|
|
323
388
|
fix: "put Bun's bin folder (~/.bun/bin) on PATH",
|
|
324
389
|
});
|
|
325
|
-
const browser = uiBrowserCheck(mine, onPath);
|
|
390
|
+
const browser = uiBrowserCheck(mine, onPath, d.host.host);
|
|
326
391
|
if (browser) checks.push(browser);
|
|
327
392
|
let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
|
|
328
393
|
try {
|
|
@@ -347,5 +412,12 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
347
412
|
fix: `fix or delete ${corrupt[0]?.dir}`,
|
|
348
413
|
});
|
|
349
414
|
|
|
350
|
-
return {
|
|
415
|
+
return {
|
|
416
|
+
host: inspectionHost(d.host),
|
|
417
|
+
queue,
|
|
418
|
+
push,
|
|
419
|
+
ready: !checks.some((c) => c.state === "fail"),
|
|
420
|
+
version: d.version,
|
|
421
|
+
checks,
|
|
422
|
+
};
|
|
351
423
|
}
|
package/src/services/finalize.ts
CHANGED
|
@@ -226,7 +226,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
226
226
|
images: o.images,
|
|
227
227
|
error: o.error,
|
|
228
228
|
replyPath: relative(run.dir, p.reply),
|
|
229
|
-
...(a.sessionId ? { sessionId: a.sessionId } : {}),
|
|
229
|
+
...(a.sessionId ? { sessionId: a.sessionId, host: a.host ?? "claude-code" } : {}),
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -88,6 +88,7 @@ export async function route(
|
|
|
88
88
|
const lane = i.laneFile === undefined ? null : readLaneFile(run, i.laneFile);
|
|
89
89
|
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
90
90
|
const a = await deps.routing.route({
|
|
91
|
+
host: deps.host.host,
|
|
91
92
|
runDir: run.dir,
|
|
92
93
|
repo: run.meta.repo,
|
|
93
94
|
profile,
|