catherd-cli 1.2.1 → 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 +146 -36
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- 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 +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +49 -19
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -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/knowledge-command.ts +50 -0
- 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 +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- 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 +16 -3
- 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/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- 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 +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- 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/source-derive.ts +32 -7
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
package/src/infra/cli.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
2
|
+
import { log } from "./log.ts";
|
|
3
|
+
import { killGroup } from "./proc.ts";
|
|
4
|
+
|
|
5
|
+
export interface CliResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
out: string;
|
|
8
|
+
err: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Process groups of CLI calls still running: an exiting catherd (Ctrl-C is exit 130) kills them. */
|
|
12
|
+
const live = new Set<number>();
|
|
13
|
+
let hooked = false;
|
|
14
|
+
function track(pid: number): void {
|
|
15
|
+
live.add(pid);
|
|
16
|
+
if (hooked) return;
|
|
17
|
+
hooked = true;
|
|
18
|
+
// a detached group gets no terminal SIGINT, and process.exit skips the timeout, so kill it on the way out
|
|
19
|
+
process.on("exit", () => {
|
|
20
|
+
for (const g of live) killGroup(g, "SIGKILL");
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs `bin <args>` without catherd's secrets and with no stdin; null when `bin` is not on PATH. A call
|
|
26
|
+
* past `timeoutMs` is killed with every process it started (it runs in its own process group) and is
|
|
27
|
+
* reported as failed, so neither a wedged CLI nor a child holding its pipes stalls the caller.
|
|
28
|
+
*/
|
|
29
|
+
export async function runCli(
|
|
30
|
+
bin: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
o: { timeoutMs: number; env?: Record<string, string>; cwd?: string; logArgs?: string[] },
|
|
33
|
+
): Promise<CliResult | null> {
|
|
34
|
+
if (!Bun.which(bin, { PATH: process.env.PATH ?? "" })) return null;
|
|
35
|
+
const logArgs = o.logArgs ?? args;
|
|
36
|
+
log("debug", "spawn", { argv: [bin, ...logArgs], env: scrubSecrets(o.env ?? {}) });
|
|
37
|
+
const p = Bun.spawn([bin, ...args], {
|
|
38
|
+
cwd: o.cwd,
|
|
39
|
+
env: scrubSecrets({ ...process.env, ...o.env }),
|
|
40
|
+
stdin: "ignore",
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
detached: true,
|
|
44
|
+
});
|
|
45
|
+
track(p.pid);
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
const late = new Promise<null>((resolve) => {
|
|
48
|
+
timer = setTimeout(() => resolve(null), o.timeoutMs);
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const done = await Promise.race([
|
|
52
|
+
Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]),
|
|
53
|
+
late,
|
|
54
|
+
]);
|
|
55
|
+
if (!done) {
|
|
56
|
+
killGroup(p.pid, "SIGKILL");
|
|
57
|
+
return { ok: false, out: "", err: `${bin} ${logArgs.join(" ")} timed out after ${o.timeoutMs} ms` };
|
|
58
|
+
}
|
|
59
|
+
const [code, out, err] = done;
|
|
60
|
+
return { ok: code === 0, out, err };
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
live.delete(p.pid);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { HostSessionRef } from "../domain/host.ts";
|
|
2
|
+
import { runCli } from "./cli.ts";
|
|
3
|
+
import { scrubSecrets } from "./env.ts";
|
|
4
|
+
|
|
5
|
+
export interface QueueCapability {
|
|
6
|
+
cli: boolean;
|
|
7
|
+
server: "supported" | "unsupported" | "unverified";
|
|
8
|
+
reason: string | null;
|
|
9
|
+
/** false when nothing was probed yet: `cli` then says nothing about the CLI */
|
|
10
|
+
checked?: false;
|
|
11
|
+
}
|
|
12
|
+
export type QueueSendResult =
|
|
13
|
+
| { outcome: "accepted"; msgId: string }
|
|
14
|
+
| { outcome: "not-submitted"; reason: string }
|
|
15
|
+
| { outcome: "ambiguous"; reason: string };
|
|
16
|
+
|
|
17
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
18
|
+
const uuid = new RegExp(`^${UUID}$`, "i");
|
|
19
|
+
const receipt = new RegExp(`^Queued message (${UUID}) for thread (${UUID})\\.$`);
|
|
20
|
+
const TIMEOUT_MS = 10000;
|
|
21
|
+
|
|
22
|
+
export async function queueCapability(
|
|
23
|
+
env: Record<string, string | undefined>,
|
|
24
|
+
run: typeof runCli = runCli,
|
|
25
|
+
): Promise<QueueCapability> {
|
|
26
|
+
try {
|
|
27
|
+
const help = await run("codex", ["queue", "--help"], {
|
|
28
|
+
timeoutMs: TIMEOUT_MS,
|
|
29
|
+
env: scrubSecrets({ ...process.env, ...env }),
|
|
30
|
+
});
|
|
31
|
+
if (
|
|
32
|
+
!help?.ok ||
|
|
33
|
+
!/\bqueue\b/.test(help.out) ||
|
|
34
|
+
!["--thread", "--message", "--remote"].every((flag) => new RegExp(`${flag}\\s`).test(help.out))
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
cli: false,
|
|
38
|
+
server: "unverified",
|
|
39
|
+
reason: "Native codex queue with --remote is unavailable; update Codex and use peek/result.",
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
cli: true,
|
|
43
|
+
server: "unverified",
|
|
44
|
+
reason: "Native server queue method enumeration is unavailable; help proves CLI support only.",
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {
|
|
48
|
+
cli: false,
|
|
49
|
+
server: "unverified",
|
|
50
|
+
reason: "Could not inspect native codex queue; use peek/result.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PROBE_TTL_MS = 60_000;
|
|
56
|
+
let known: { at: number; capability: QueueCapability } | null = null;
|
|
57
|
+
let probing: Promise<void> | null = null;
|
|
58
|
+
|
|
59
|
+
/** The capability a one-shot command reports without probing: it never waits, and doctor checks it. */
|
|
60
|
+
export const UNCHECKED_QUEUE: QueueCapability = {
|
|
61
|
+
cli: false,
|
|
62
|
+
server: "unverified",
|
|
63
|
+
reason: "Native codex queue is not checked by this command; catherd doctor checks it.",
|
|
64
|
+
checked: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The last known queue capability, without waiting on the Codex CLI: peek and status never wait (spec §3.7).
|
|
69
|
+
* A missing or stale answer starts one probe in the background; until it lands the capability reads unchecked.
|
|
70
|
+
*/
|
|
71
|
+
export function knownQueueCapability(
|
|
72
|
+
env: Record<string, string | undefined>,
|
|
73
|
+
run: typeof runCli = runCli,
|
|
74
|
+
now: number = Date.now(),
|
|
75
|
+
): QueueCapability {
|
|
76
|
+
if (!probing && (!known || now - known.at >= PROBE_TTL_MS))
|
|
77
|
+
probing = queueCapability(env, run)
|
|
78
|
+
.then((capability) => {
|
|
79
|
+
known = { at: now, capability };
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
probing = null;
|
|
83
|
+
});
|
|
84
|
+
return (
|
|
85
|
+
known?.capability ?? {
|
|
86
|
+
cli: false,
|
|
87
|
+
server: "unverified",
|
|
88
|
+
reason: "Native codex queue is being checked; peek again for the result.",
|
|
89
|
+
checked: false,
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Settles the background probe knownQueueCapability started, if any (a test seam). */
|
|
95
|
+
export async function queueProbeSettled(): Promise<void> {
|
|
96
|
+
await probing;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Settles any background probe and forgets its answer (a test seam). */
|
|
100
|
+
export async function resetQueueProbe(): Promise<void> {
|
|
101
|
+
await probing;
|
|
102
|
+
known = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sendToCodexQueue(
|
|
106
|
+
target: HostSessionRef,
|
|
107
|
+
content: string,
|
|
108
|
+
env: Record<string, string | undefined>,
|
|
109
|
+
run: typeof runCli = runCli,
|
|
110
|
+
): Promise<QueueSendResult> {
|
|
111
|
+
if (target.host !== "codex" || target.sessionId.length !== 36 || !uuid.test(target.sessionId))
|
|
112
|
+
return {
|
|
113
|
+
outcome: "not-submitted",
|
|
114
|
+
reason: "Queue requires a validated original Codex thread UUID; use peek/result.",
|
|
115
|
+
};
|
|
116
|
+
if (!content.trim()) return { outcome: "not-submitted", reason: "Queue message is empty." };
|
|
117
|
+
const capability = await queueCapability(env, run);
|
|
118
|
+
if (!capability.cli || capability.server === "unsupported")
|
|
119
|
+
return {
|
|
120
|
+
outcome: "not-submitted",
|
|
121
|
+
reason: capability.reason ?? "Native queue is unavailable; use peek/result.",
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
const args = ["queue", "--remote", "unix://", "--thread", target.sessionId, "--message", content];
|
|
125
|
+
const result = await run("codex", args, {
|
|
126
|
+
timeoutMs: TIMEOUT_MS,
|
|
127
|
+
env: scrubSecrets({ ...process.env, ...env }),
|
|
128
|
+
logArgs: [...args.slice(0, -1), "[redacted]"],
|
|
129
|
+
});
|
|
130
|
+
if (result === null)
|
|
131
|
+
return { outcome: "not-submitted", reason: "Codex executable is unavailable; use peek/result." };
|
|
132
|
+
const match = result.ok ? receipt.exec(result.out.trim()) : null;
|
|
133
|
+
if (match && match[2]?.toLowerCase() === target.sessionId.toLowerCase())
|
|
134
|
+
return { outcome: "accepted", msgId: match[1]! };
|
|
135
|
+
if (
|
|
136
|
+
!result.ok &&
|
|
137
|
+
!result.out.trim() &&
|
|
138
|
+
(/^Error: the remote app server does not support thread\/queue\/add; update or restart the remote app server(?:\n|$)/.test(
|
|
139
|
+
result.err,
|
|
140
|
+
) ||
|
|
141
|
+
/^error: (?:unrecognized subcommand 'queue'|unexpected argument '--remote' found)(?:\n|$)/.test(
|
|
142
|
+
result.err,
|
|
143
|
+
) ||
|
|
144
|
+
/^Error: failed to connect to remote app server at `unix:\/\/[^`\n]+`: [^\n]+(?:\n|$)/.test(
|
|
145
|
+
result.err,
|
|
146
|
+
))
|
|
147
|
+
)
|
|
148
|
+
return {
|
|
149
|
+
outcome: "not-submitted",
|
|
150
|
+
reason:
|
|
151
|
+
"Native queue refused before submission; check CLI/server support and the existing native endpoint, then use peek/result.",
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
outcome: "ambiguous",
|
|
155
|
+
reason:
|
|
156
|
+
"Native queue invocation has no verified receipt; use peek/result. Retrying may duplicate input.",
|
|
157
|
+
};
|
|
158
|
+
} catch {
|
|
159
|
+
return {
|
|
160
|
+
outcome: "ambiguous",
|
|
161
|
+
reason:
|
|
162
|
+
"Native queue invocation failed without a verified receipt; use peek/result. Retrying may duplicate input.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { sessionKey, type HostSessionRef } from "../domain/host.ts";
|
|
5
|
+
import { awaitsCollect, dispatchPaths } from "./dispatch-dir.ts";
|
|
6
|
+
import { withFileLockSync } from "./filelock.ts";
|
|
7
|
+
import { readVersioned, writeJsonAtomic } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
const AttemptSchema = z
|
|
10
|
+
.looseObject({
|
|
11
|
+
attemptId: z.string().min(1),
|
|
12
|
+
target: z.object({
|
|
13
|
+
host: z.enum(["codex", "claude-code"]),
|
|
14
|
+
sessionId: z.string().min(1),
|
|
15
|
+
hostSessionId: z.string().nullable(),
|
|
16
|
+
name: z.string().nullable(),
|
|
17
|
+
}),
|
|
18
|
+
eventIds: z.array(z.string().min(1)).min(1),
|
|
19
|
+
at: z.iso.datetime(),
|
|
20
|
+
status: z.enum(["submitting", "accepted", "ambiguous", "failed"]),
|
|
21
|
+
msgId: z.string().nullable(),
|
|
22
|
+
reason: z.string().nullable(),
|
|
23
|
+
})
|
|
24
|
+
.refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
|
|
25
|
+
const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
|
|
26
|
+
export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
|
|
27
|
+
export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
|
|
28
|
+
|
|
29
|
+
export function readDelivery(dir: string): DeliveryAttempt[] {
|
|
30
|
+
const file = dispatchPaths(dir).delivery;
|
|
31
|
+
return existsSync(file)
|
|
32
|
+
? readVersioned(file, DeliverySchema, 1, {
|
|
33
|
+
fix: "Use peek/result; delivery evidence is unreadable and retry may duplicate input.",
|
|
34
|
+
}).attempts
|
|
35
|
+
: [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Short shared lock: distinct stalled/finished event claims must not lose each other's updates. */
|
|
39
|
+
export function writeDeliveryAttempt(dir: string, attempt: DeliveryAttempt): void {
|
|
40
|
+
const parsed = AttemptSchema.safeParse(attempt);
|
|
41
|
+
if (!parsed.success) throw new CatherdError("E_INPUT_INVALID", "Invalid delivery attempt");
|
|
42
|
+
const file = dispatchPaths(dir).delivery;
|
|
43
|
+
withFileLockSync(file, () => {
|
|
44
|
+
const document = existsSync(file)
|
|
45
|
+
? readVersioned(file, DeliverySchema, 1)
|
|
46
|
+
: { schema: 1 as const, attempts: [] as DeliveryAttempt[] };
|
|
47
|
+
const prior = document.attempts.findIndex((a) => a.attemptId === attempt.attemptId);
|
|
48
|
+
if (prior < 0) document.attempts.push(parsed.data);
|
|
49
|
+
else {
|
|
50
|
+
const sent = document.attempts[prior]!;
|
|
51
|
+
if (
|
|
52
|
+
JSON.stringify(sent.target) !== JSON.stringify(parsed.data.target) ||
|
|
53
|
+
JSON.stringify(sent.eventIds) !== JSON.stringify(attempt.eventIds) ||
|
|
54
|
+
sent.at !== attempt.at
|
|
55
|
+
)
|
|
56
|
+
throw new CatherdError(
|
|
57
|
+
"E_INPUT_INVALID",
|
|
58
|
+
"Delivery target, events and submission time are immutable",
|
|
59
|
+
);
|
|
60
|
+
document.attempts[prior] = { ...sent, ...parsed.data };
|
|
61
|
+
}
|
|
62
|
+
writeJsonAtomic(file, document);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deliveryState(dir: string, target: HostSessionRef, eventId: string): DeliveryState {
|
|
67
|
+
try {
|
|
68
|
+
const event = JSON.parse(eventId) as unknown;
|
|
69
|
+
if (Array.isArray(event) && event[2] === "finished" && !awaitsCollect(dir)) return "collected";
|
|
70
|
+
} catch {
|
|
71
|
+
/* Invalid event text cannot imply collection. */
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const attempts = readDelivery(dir).filter(
|
|
75
|
+
(a) => sessionKey(a.target) === sessionKey(target) && a.eventIds.includes(eventId),
|
|
76
|
+
);
|
|
77
|
+
if (attempts.some((a) => a.status === "accepted")) return "enqueue-accepted";
|
|
78
|
+
if (attempts.some((a) => a.status === "submitting" || a.status === "ambiguous")) return "ambiguous";
|
|
79
|
+
return "pending";
|
|
80
|
+
} catch {
|
|
81
|
+
return "ambiguous";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -40,6 +40,7 @@ export function dispatchPaths(dir: string) {
|
|
|
40
40
|
failover: join(dir, "failover.json"),
|
|
41
41
|
/** the message that announced this dispatch's record to its session, once sent (plan 10) */
|
|
42
42
|
notified: join(dir, "notified.json"),
|
|
43
|
+
delivery: join(dir, "delivery.json"),
|
|
43
44
|
/** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
|
|
44
45
|
stall: join(dir, "stall.json"),
|
|
45
46
|
/** the message that announced the stall, once sent */
|
package/src/infra/env.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
|
|
6
6
|
*/
|
|
7
7
|
const SECRET_ENV = new Set([
|
|
8
|
+
"CODEX_THREAD_ID",
|
|
9
|
+
"CODEX_SESSION_ID",
|
|
10
|
+
"CATHERD_ORCHESTRATION_HOST",
|
|
8
11
|
"TYPESAFE_API_KEY",
|
|
9
12
|
// spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
|
|
10
13
|
"ARTIFICIAL_ANALYSIS_API_KEY",
|
|
@@ -39,7 +42,7 @@ export function workerEnv(
|
|
|
39
42
|
overrides: Record<string, string>,
|
|
40
43
|
cwd: string,
|
|
41
44
|
): Record<string, string> {
|
|
42
|
-
return { ...scrubSecrets(base
|
|
45
|
+
return { ...scrubSecrets({ ...base, ...overrides }), PWD: cwd };
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** What a preflight check may see; everything else, credentials included, stays out (spec §4.7). */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HostContext, KnownHost, OrchestrationHost } from "../domain/host.ts";
|
|
2
|
+
import { readSessionEnv, sessionFileFor } from "./claude-session.ts";
|
|
3
|
+
|
|
4
|
+
export function resolveHost(e: {
|
|
5
|
+
clientName?: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
terminalHost?: OrchestrationHost;
|
|
8
|
+
}): HostContext {
|
|
9
|
+
const conflict = (message: string): HostContext => ({ host: "unknown", session: null, conflict: message });
|
|
10
|
+
const hosts = new Set<KnownHost>();
|
|
11
|
+
const client = e.clientName;
|
|
12
|
+
if (client === "codex-mcp-client") hosts.add("codex");
|
|
13
|
+
if (client === "claude-code") hosts.add("claude-code");
|
|
14
|
+
const launcher = e.env.CATHERD_ORCHESTRATION_HOST;
|
|
15
|
+
if (launcher === "codex" || launcher === "claude-code") hosts.add(launcher);
|
|
16
|
+
const claude = readSessionEnv(e.env);
|
|
17
|
+
if (claude) hosts.add("claude-code");
|
|
18
|
+
// UUIDs are case-insensitive: one spelling, so the launcher's and a request's casing name the same thread
|
|
19
|
+
const thread = e.env.CODEX_THREAD_ID?.toLowerCase();
|
|
20
|
+
const session = e.env.CODEX_SESSION_ID?.toLowerCase();
|
|
21
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
if (thread !== undefined || session !== undefined) {
|
|
23
|
+
hosts.add("codex");
|
|
24
|
+
if ((thread !== undefined && !uuid.test(thread)) || (session !== undefined && !uuid.test(session)))
|
|
25
|
+
return conflict("Codex session IDs must be UUIDs; remove invalid CODEX_THREAD_ID / CODEX_SESSION_ID");
|
|
26
|
+
if (thread && session && thread !== session)
|
|
27
|
+
return conflict("CODEX_THREAD_ID and CODEX_SESSION_ID disagree; correct the launcher environment");
|
|
28
|
+
}
|
|
29
|
+
if (hosts.size > 1)
|
|
30
|
+
return conflict(
|
|
31
|
+
"Initialized client and launcher session evidence disagree; remove the other host's environment",
|
|
32
|
+
);
|
|
33
|
+
const host = [...hosts][0] ?? e.terminalHost ?? "unknown";
|
|
34
|
+
if (host === "codex")
|
|
35
|
+
return {
|
|
36
|
+
host,
|
|
37
|
+
session:
|
|
38
|
+
thread || session ? { host, sessionId: thread || session!, hostSessionId: null, name: null } : null,
|
|
39
|
+
conflict: null,
|
|
40
|
+
};
|
|
41
|
+
if (host === "claude-code" && claude) {
|
|
42
|
+
const file = sessionFileFor(claude);
|
|
43
|
+
const id = file?.sessionId ?? claude.sessionId;
|
|
44
|
+
return {
|
|
45
|
+
host,
|
|
46
|
+
session: id
|
|
47
|
+
? {
|
|
48
|
+
host,
|
|
49
|
+
sessionId: id,
|
|
50
|
+
hostSessionId: file?.hostSessionId ?? claude.hostSessionId,
|
|
51
|
+
name: file?.name ?? null,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
conflict: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { host, session: null, conflict: null };
|
|
58
|
+
}
|
package/src/infra/paths.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
function root(kind: "config" | "data"): string {
|
|
5
|
-
// set for an isolated worker whose XDG_CONFIG_HOME points elsewhere, so its `catherd lock` reads
|
|
5
|
+
// set for an isolated worker whose XDG_CONFIG_HOME or HOME points elsewhere, so its `catherd lock` reads
|
|
6
|
+
// this config and takes this lock dir
|
|
6
7
|
if (kind === "config" && process.env.CATHERD_CONFIG_DIR) return process.env.CATHERD_CONFIG_DIR;
|
|
8
|
+
if (kind === "data" && process.env.CATHERD_DATA_DIR) return process.env.CATHERD_DATA_DIR;
|
|
7
9
|
const home = process.env.CATHERD_HOME;
|
|
8
10
|
if (home) return join(home, kind);
|
|
9
11
|
if (kind === "config") return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "catherd");
|
package/src/infra/peer-inbox.ts
CHANGED
|
@@ -51,10 +51,12 @@ export function sendToInbox(
|
|
|
51
51
|
const socketPath = target.socketPath;
|
|
52
52
|
return new Promise<SendResult>((resolve) => {
|
|
53
53
|
let settled = false;
|
|
54
|
+
// once the frame is being written a failure may still have delivered it: such a result carries its msg_id
|
|
55
|
+
let writing = false;
|
|
54
56
|
const done = (r: SendResult) => {
|
|
55
57
|
if (settled) return;
|
|
56
58
|
settled = true;
|
|
57
|
-
resolve(r);
|
|
59
|
+
resolve(writing && r.outcome === "error" ? { ...r, msgId } : r);
|
|
58
60
|
};
|
|
59
61
|
let s: ReturnType<typeof createConnection>;
|
|
60
62
|
try {
|
|
@@ -77,6 +79,7 @@ export function sendToInbox(
|
|
|
77
79
|
});
|
|
78
80
|
s.on("connect", () => {
|
|
79
81
|
clearTimeout(timer);
|
|
82
|
+
writing = true;
|
|
80
83
|
s.write(frameText(content, priority, target.token, msgId), (err) => {
|
|
81
84
|
if (err) {
|
|
82
85
|
s.destroy();
|
package/src/infra/supervisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
1
2
|
import type { Subprocess } from "bun";
|
|
2
3
|
import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -139,7 +140,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
139
140
|
fds.push(stderr);
|
|
140
141
|
child = Bun.spawn([spec.cmd, ...spec.args], {
|
|
141
142
|
cwd: spec.cwd,
|
|
142
|
-
env: spec.env,
|
|
143
|
+
env: scrubSecrets(spec.env),
|
|
143
144
|
stdin,
|
|
144
145
|
stdout,
|
|
145
146
|
stderr,
|
|
@@ -149,7 +150,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
149
150
|
backend: spec.backend,
|
|
150
151
|
dispatch: spec.dispatchDir,
|
|
151
152
|
argv: [spec.cmd, ...spec.args],
|
|
152
|
-
env: spec.env,
|
|
153
|
+
env: scrubSecrets(spec.env),
|
|
153
154
|
cwd: spec.cwd,
|
|
154
155
|
pid: child.pid,
|
|
155
156
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KnownHost } from "../domain/host.ts";
|
|
1
2
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { BackendAdapter } from "../adapters/backend.ts";
|
|
@@ -45,6 +46,7 @@ export interface AdmitInput {
|
|
|
45
46
|
failoverOf?: string;
|
|
46
47
|
/** the dispatching session (spec §3.3); absent means the session this process serves */
|
|
47
48
|
sessionId?: string | null;
|
|
49
|
+
host?: KnownHost;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Spec §3.3: SIGTERM, then SIGKILL this long after. */
|
|
@@ -162,18 +164,40 @@ export async function admit(
|
|
|
162
164
|
throw new CatherdError("E_ADMIT_THREAD", `"${i.thread}" is not a ${adapter.id} thread id`, {
|
|
163
165
|
fix: "pass the thread from the role's earlier record, or none for a fresh thread",
|
|
164
166
|
});
|
|
167
|
+
// a resumed thread lives in the home it started in (CODEX_HOME), whatever the profile says now
|
|
168
|
+
const started = i.thread === null ? undefined : recordsOnThread(run, rung.backend, i.thread).at(-1);
|
|
169
|
+
const network = rc.network !== false;
|
|
170
|
+
// spec 1.3 §3.2: a backend that fixes a thread's access when it starts it refuses another on resume
|
|
171
|
+
if (started && adapter.resume.sameAccessOnly && started.access !== rc.access)
|
|
172
|
+
throw new CatherdError(
|
|
173
|
+
"E_ADMIT_THREAD",
|
|
174
|
+
`${adapter.id} keeps the access a thread started with: ${i.thread} ran ${started.access}, and the ${i.role} role runs ${rc.access}`,
|
|
175
|
+
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
176
|
+
);
|
|
177
|
+
// and the network grant: grok keeps a session's sandbox profile, agy's isolated homes differ by it.
|
|
178
|
+
// A record from before 1.3 carries no grant to compare.
|
|
179
|
+
if (started?.network !== undefined && adapter.resume.sameAccessOnly && started.network !== network)
|
|
180
|
+
throw new CatherdError(
|
|
181
|
+
"E_ADMIT_THREAD",
|
|
182
|
+
`${adapter.id} keeps the network grant a thread started with: ${i.thread} ran ${started.network ? "with" : "without"} the network, and the ${i.role} role runs ${network ? "with" : "without"} it`,
|
|
183
|
+
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
184
|
+
);
|
|
165
185
|
const owns = i.lane === null ? [] : laneOwns(run, i.lane);
|
|
166
186
|
const id = newDispatchId();
|
|
167
187
|
const dir = join(roleDir(run, i.name), id);
|
|
168
188
|
const p = dispatchPaths(dir);
|
|
169
|
-
// a resumed thread lives in the home it started in (CODEX_HOME), whatever the profile says now
|
|
170
|
-
const started = i.thread === null ? undefined : recordsOnThread(run, rung.backend, i.thread).at(-1);
|
|
171
189
|
const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
|
|
172
|
-
await prepared(adapter, {
|
|
190
|
+
await prepared(adapter, {
|
|
191
|
+
rung,
|
|
192
|
+
access: rc.access,
|
|
193
|
+
isolated,
|
|
194
|
+
repo: run.meta.repo,
|
|
195
|
+
network,
|
|
196
|
+
});
|
|
173
197
|
const plan = adapter.plan({
|
|
174
198
|
rung,
|
|
175
199
|
access: rc.access,
|
|
176
|
-
network
|
|
200
|
+
network,
|
|
177
201
|
thread: i.thread,
|
|
178
202
|
isolated,
|
|
179
203
|
repo: run.meta.repo,
|
|
@@ -183,7 +207,6 @@ export async function admit(
|
|
|
183
207
|
});
|
|
184
208
|
|
|
185
209
|
await finalizeFinished(run, deps.now(), onRecorded);
|
|
186
|
-
const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
|
|
187
210
|
return withFileLock(runPaths(run.dir).admission, async () => {
|
|
188
211
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
189
212
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
@@ -223,6 +246,9 @@ export async function admit(
|
|
|
223
246
|
throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
|
|
224
247
|
fix: "ask the user to raise profile.budget, or finish the run with what landed",
|
|
225
248
|
});
|
|
249
|
+
const before = await statusSnapshot(run.meta.repo);
|
|
250
|
+
const session = currentSession(deps);
|
|
251
|
+
const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
|
|
226
252
|
const admitted: Admit = {
|
|
227
253
|
schema: 1,
|
|
228
254
|
runId: run.id,
|
|
@@ -239,11 +265,12 @@ export async function admit(
|
|
|
239
265
|
...(i.failoverOf ? { failoverOf: i.failoverOf } : {}),
|
|
240
266
|
access: rc.access,
|
|
241
267
|
isolated,
|
|
268
|
+
network,
|
|
242
269
|
cliVersion: probe.version,
|
|
243
270
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
244
271
|
repo: run.meta.repo,
|
|
245
|
-
before
|
|
246
|
-
...(sessionId ? { sessionId } : {}),
|
|
272
|
+
before,
|
|
273
|
+
...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
|
|
247
274
|
};
|
|
248
275
|
ensurePrivateDir(dir);
|
|
249
276
|
// spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import {
|
|
2
3
|
existsSync,
|
|
3
4
|
lstatSync,
|
|
@@ -44,10 +45,10 @@ export interface Planned {
|
|
|
44
45
|
links: Map<string, string>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export function plan(extra: string[] = []): Planned {
|
|
48
|
+
export function plan(host: OrchestrationHost, extra: string[] = [], selected?: string[]): Planned {
|
|
48
49
|
const files = new Map<string, AgentFile[]>();
|
|
49
|
-
for (const name of new Set([...linkedProfiles(), ...extra.filter(profileExists)]))
|
|
50
|
-
files.set(name, agentFiles(getProfile(name), VERSION));
|
|
50
|
+
for (const name of new Set(selected ?? [...linkedProfiles(), ...extra.filter(profileExists)]))
|
|
51
|
+
files.set(name, agentFiles(getProfile(name, host), VERSION));
|
|
51
52
|
const links = new Map<string, string>();
|
|
52
53
|
for (const name of linkedProfiles())
|
|
53
54
|
for (const f of files.get(name) ?? [])
|
|
@@ -71,6 +72,27 @@ export interface Synced {
|
|
|
71
72
|
newSessionNeededFor: string[];
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
export function pruneProfileAgents(name: string): Synced {
|
|
76
|
+
const dir = join(agentsRoot(), name);
|
|
77
|
+
const pruned: string[] = [];
|
|
78
|
+
const target = claudeAgentsDir();
|
|
79
|
+
if (existsSync(target))
|
|
80
|
+
for (const entry of readdirSync(target)) {
|
|
81
|
+
const link = join(target, entry);
|
|
82
|
+
if (isOurLink(link) && readlinkSync(link).startsWith(`${dir}${sep}`)) {
|
|
83
|
+
rmSync(link);
|
|
84
|
+
pruned.push(entry);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
pruned.sort();
|
|
89
|
+
return {
|
|
90
|
+
linked: [],
|
|
91
|
+
pruned,
|
|
92
|
+
newSessionNeededFor: pruned.map((entry) => basename(entry, ".md")),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/**
|
|
75
97
|
* Writes the planned agent files and links, prunes catherd's stale ones, and says what changed. The
|
|
76
98
|
* ownership check runs before the first write, so no caller can replace a file catherd does not own.
|
|
@@ -122,8 +144,11 @@ export function apply(p: Planned, removed: string[] = []): Synced {
|
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
/** What `doctor` compares: each link that should exist, and whether it and its file are current. */
|
|
125
|
-
export function agentLinkState(
|
|
126
|
-
|
|
147
|
+
export function agentLinkState(
|
|
148
|
+
host: OrchestrationHost,
|
|
149
|
+
selected?: string[],
|
|
150
|
+
): { missing: string[]; stale: string[]; ok: string[] } {
|
|
151
|
+
const p = plan(host, [], selected);
|
|
127
152
|
const out = { missing: [] as string[], stale: [] as string[], ok: [] as string[] };
|
|
128
153
|
for (const [name, files] of p.files)
|
|
129
154
|
for (const f of files) {
|