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
|
@@ -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/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. */
|
|
@@ -205,7 +207,6 @@ export async function admit(
|
|
|
205
207
|
});
|
|
206
208
|
|
|
207
209
|
await finalizeFinished(run, deps.now(), onRecorded);
|
|
208
|
-
const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
|
|
209
210
|
return withFileLock(runPaths(run.dir).admission, async () => {
|
|
210
211
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
211
212
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
@@ -245,6 +246,9 @@ export async function admit(
|
|
|
245
246
|
throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
|
|
246
247
|
fix: "ask the user to raise profile.budget, or finish the run with what landed",
|
|
247
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);
|
|
248
252
|
const admitted: Admit = {
|
|
249
253
|
schema: 1,
|
|
250
254
|
runId: run.id,
|
|
@@ -265,8 +269,8 @@ export async function admit(
|
|
|
265
269
|
cliVersion: probe.version,
|
|
266
270
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
267
271
|
repo: run.meta.repo,
|
|
268
|
-
before
|
|
269
|
-
...(sessionId ? { sessionId } : {}),
|
|
272
|
+
before,
|
|
273
|
+
...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
|
|
270
274
|
};
|
|
271
275
|
ensurePrivateDir(dir);
|
|
272
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) {
|
package/src/services/backends.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
2
|
+
import { nativeClaudeIssue } from "../domain/profile-rules.ts";
|
|
1
3
|
import type { BackendAdapter, Probe } from "../adapters/backend.ts";
|
|
2
4
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
5
|
import "../adapters/all.ts";
|
|
@@ -80,3 +82,8 @@ export function standInFor(failover: Record<string, string>, rung: string, repo?
|
|
|
80
82
|
const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
|
|
81
83
|
return byAdapter && formatRung(byAdapter);
|
|
82
84
|
}
|
|
85
|
+
|
|
86
|
+
export function assertNativeHost(rung: string, host: OrchestrationHost): void {
|
|
87
|
+
const issue = nativeClaudeIssue(rung, host, "rung");
|
|
88
|
+
if (issue) throw new CatherdError("E_CONFIG_INVALID", `${issue.message}; ${issue.fix}`, { fix: issue.fix });
|
|
89
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertNativeHost } from "./backends.ts";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
3
4
|
import { assertId, parseRung } from "../domain/ids.ts";
|
|
@@ -34,7 +35,7 @@ import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize
|
|
|
34
35
|
import type { Deps } from "./ports.ts";
|
|
35
36
|
import { route } from "./lane-service.ts";
|
|
36
37
|
import { findRun, readRecords, readRoutes, type Run } from "./run-store.ts";
|
|
37
|
-
import { claimRun, ownsRun } from "./sessions.ts";
|
|
38
|
+
import { claimRun, ownsRun, runOwner } from "./sessions.ts";
|
|
38
39
|
import { type NotesPatch, refreshState } from "./state.ts";
|
|
39
40
|
|
|
40
41
|
export interface DispatchInput {
|
|
@@ -101,17 +102,21 @@ export interface Stalled {
|
|
|
101
102
|
export const stallHooks = new Set<(s: Stalled) => void>();
|
|
102
103
|
|
|
103
104
|
/** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
|
|
105
|
+
/** A stall's quiet time; 0 when stall.json is being written or damaged, so one bad file never blocks a notice. */
|
|
106
|
+
export function readStallQuietMs(dir: string): number {
|
|
107
|
+
try {
|
|
108
|
+
return Number(JSON.parse(readFileSync(dispatchPaths(dir).stall, "utf8")).quietMs) || 0;
|
|
109
|
+
} catch {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
export function stallPoll(run: Run, d: Dispatch): () => void {
|
|
105
115
|
let seen = false;
|
|
106
116
|
return () => {
|
|
107
117
|
if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
|
|
108
118
|
seen = true;
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
|
|
112
|
-
} catch {
|
|
113
|
-
// being written: the stall is reported without its length
|
|
114
|
-
}
|
|
119
|
+
const quietMs = readStallQuietMs(d.dir);
|
|
115
120
|
for (const hook of stallHooks) {
|
|
116
121
|
try {
|
|
117
122
|
hook({ run, d, quietMs });
|
|
@@ -294,6 +299,8 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
|
|
|
294
299
|
hints.push(`${i.rung} is not on ${i.lane}'s routed ladder: dispatched at ${routed.rung}`);
|
|
295
300
|
}
|
|
296
301
|
}
|
|
302
|
+
// the rung that runs, after routing: an off-ladder native rung routing replaced is never launched
|
|
303
|
+
assertNativeHost(rung, deps.host.host);
|
|
297
304
|
const { d, specPath } = await admit(
|
|
298
305
|
deps,
|
|
299
306
|
run,
|
|
@@ -493,6 +500,8 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
|
|
|
493
500
|
// else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
|
|
494
501
|
const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
|
|
495
502
|
if (!standIn) return { hints, started: null, pause: paused };
|
|
503
|
+
// the stand-in answers to whoever owns the run now, which a claim from another host may have changed
|
|
504
|
+
assertNativeHost(standIn, runOwner(run)?.host ?? deps.host.host);
|
|
496
505
|
if (parseRung(standIn).backend === "claude") {
|
|
497
506
|
const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
|
|
498
507
|
return {
|
|
@@ -517,6 +526,7 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
|
|
|
517
526
|
failoverOf: d.admit.dispatchId,
|
|
518
527
|
// the stand-in answers to the session that dispatched the limited role, whoever fails it over
|
|
519
528
|
sessionId: d.admit.sessionId ?? null,
|
|
529
|
+
host: d.admit.host,
|
|
520
530
|
});
|
|
521
531
|
} catch (e) {
|
|
522
532
|
if (!isCatherdError(e)) throw e;
|
|
@@ -36,6 +36,7 @@ const AdmitSchema = z.looseObject({
|
|
|
36
36
|
before: z.record(z.string(), z.string()),
|
|
37
37
|
/** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
|
|
38
38
|
sessionId: z.string().optional(),
|
|
39
|
+
host: z.enum(["claude-code", "codex"]).optional(),
|
|
39
40
|
});
|
|
40
41
|
export type Admit = z.infer<typeof AdmitSchema>;
|
|
41
42
|
|
|
@@ -65,7 +66,7 @@ export function listDispatches(run: Run): Dispatch[] {
|
|
|
65
66
|
if (!id.isDirectory()) continue;
|
|
66
67
|
const dir = join(roles, name.name, id.name);
|
|
67
68
|
try {
|
|
68
|
-
out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
|
|
69
|
+
out.push({ dir, admit: decodeAdmit(readVersioned(admitPath(dir), AdmitSchema, 1)) });
|
|
69
70
|
} catch {
|
|
70
71
|
// admission never finished writing this folder
|
|
71
72
|
}
|
|
@@ -174,3 +175,7 @@ export function readFailover(dir: string): FailoverFile | null {
|
|
|
174
175
|
export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
|
|
175
176
|
return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
|
|
176
177
|
}
|
|
178
|
+
|
|
179
|
+
function decodeAdmit(a: Admit): Admit {
|
|
180
|
+
return a.sessionId && !a.host ? { ...a, host: "claude-code" } : a;
|
|
181
|
+
}
|