catherd-cli 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +118 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
package/src/adapters/backend.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ErrorCode } from "../domain/errors.ts";
|
|
|
2
2
|
import type { ADAPTER_IDS, Rung } from "../domain/ids.ts";
|
|
3
3
|
import type { BillingMode } from "../domain/cost.ts";
|
|
4
4
|
import type { Access, ExitInfo, RunStatus, Tokens } from "../domain/record.ts";
|
|
5
|
-
|
|
5
|
+
import type { CliResult } from "./cli.ts";
|
|
6
6
|
type AdapterId = (typeof ADAPTER_IDS)[number];
|
|
7
7
|
|
|
8
8
|
export interface Probe {
|
|
@@ -18,6 +18,14 @@ export interface Probe {
|
|
|
18
18
|
problems: { code: ErrorCode; message: string; fix: string }[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** A worker's shell for doctor's probes: `run` is `sh -c <script> _ <args…>`; `close` removes its scratch dir. */
|
|
22
|
+
export interface AccessShell {
|
|
23
|
+
/** how it runs, for the doctor rows: "codex sandbox", "an unsandboxed shell" */
|
|
24
|
+
how: string;
|
|
25
|
+
run(script: string, args: string[]): Promise<CliResult | null>;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
export interface DiscoveredModel {
|
|
22
30
|
id: string;
|
|
23
31
|
efforts: string[];
|
|
@@ -28,6 +36,8 @@ export interface DiscoveredModel {
|
|
|
28
36
|
export interface RunRequest {
|
|
29
37
|
rung: Rung;
|
|
30
38
|
access: Access;
|
|
39
|
+
/** spec §5: a workspace-write role's network and loopback grants; false only when the profile says `network: false` */
|
|
40
|
+
network?: boolean;
|
|
31
41
|
thread: string | null;
|
|
32
42
|
isolated: boolean;
|
|
33
43
|
repo: string;
|
|
@@ -56,6 +66,11 @@ export interface EventDelta {
|
|
|
56
66
|
requestInput?: number;
|
|
57
67
|
costUsd?: number;
|
|
58
68
|
lastEvent?: string;
|
|
69
|
+
/**
|
|
70
|
+
* what the worker is doing, in words, when the line says (spec §3.7 `peek`): a command (`$ bun test`), a file
|
|
71
|
+
* edit (`edit src/a.ts`) or a message line
|
|
72
|
+
*/
|
|
73
|
+
activity?: string;
|
|
59
74
|
failure?: string;
|
|
60
75
|
limit?: boolean;
|
|
61
76
|
tooOld?: boolean;
|
|
@@ -120,10 +135,10 @@ export interface BackendAdapter {
|
|
|
120
135
|
/** Spec §4.5: this backend's own stand-in for a rung on a usage limit, when the profile names none. */
|
|
121
136
|
failoverFor?(rung: Rung, repo?: string): Rung | null;
|
|
122
137
|
/**
|
|
123
|
-
* Spec §
|
|
124
|
-
*
|
|
138
|
+
* Spec §5 and §12: a shell that runs a command the way this backend's workspace-write worker runs one,
|
|
139
|
+
* with the grants the worker gets, for doctor's access probes; a string says why it cannot be tested here.
|
|
125
140
|
*/
|
|
126
|
-
|
|
141
|
+
accessShell?(o: { network: boolean }): Promise<AccessShell | string>;
|
|
127
142
|
/** Spec §10.3: why this backend's isolation is weak; doctor warns when a profile uses it. */
|
|
128
143
|
isolationNote?: string;
|
|
129
144
|
graceAfterFinalMs: number | null;
|
|
@@ -2,6 +2,7 @@ import { CatherdError } from "../../domain/errors.ts";
|
|
|
2
2
|
import type { Rung } from "../../domain/ids.ts";
|
|
3
3
|
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
4
4
|
import {
|
|
5
|
+
type AccessShell,
|
|
5
6
|
type BackendAdapter,
|
|
6
7
|
compareVersions,
|
|
7
8
|
type EventDelta,
|
|
@@ -12,6 +13,10 @@ import {
|
|
|
12
13
|
type RunRequest,
|
|
13
14
|
type SpawnPlan,
|
|
14
15
|
} from "../backend.ts";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { claudeHome } from "../../infra/paths.ts";
|
|
19
|
+
import { dockerSocket, scratchShell, writableRoots } from "../access.ts";
|
|
15
20
|
import { jsonOf, runCli } from "../cli.ts";
|
|
16
21
|
import {
|
|
17
22
|
CLAUDE_LIMIT,
|
|
@@ -60,6 +65,75 @@ export const CLAUDE_ACCESS: Record<Access, string[]> = {
|
|
|
60
65
|
full: ["--permission-mode", "bypassPermissions"],
|
|
61
66
|
};
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Spec §5 for headless Claude Code. Its Bash runs unsandboxed unless the user turned Claude Code's own
|
|
70
|
+
* sandbox on (`sandbox.enabled`); then these settings, merged over the user's, add the lock and temp dirs,
|
|
71
|
+
* loopback binds, the Docker socket and `docker` itself (which cannot run inside that sandbox). Outbound
|
|
72
|
+
* domains stay the user's `sandbox.network.allowedDomains`: doctor's `access:claude-code` row says when
|
|
73
|
+
* the registry is not among them. `network: false` drops the network grants and the web tools (and with
|
|
74
|
+
* them Docker: the socket and `docker *` are network grants) and sets `allowLocalBinding: false`, so the
|
|
75
|
+
* user's own `true` does not carry over (their allowedDomains and other array grants merge in and stay;
|
|
76
|
+
* doctor says `network: false` is not enforced by claude-code's shell). With the user's sandbox on, `enabled: true`
|
|
77
|
+
* goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off.
|
|
78
|
+
*/
|
|
79
|
+
export function claudeAccessArgs(access: Access, network = true, repo?: string): string[] {
|
|
80
|
+
const base = CLAUDE_ACCESS[access];
|
|
81
|
+
if (access !== "workspace-write") return base;
|
|
82
|
+
const sock = dockerSocket();
|
|
83
|
+
const sandbox = {
|
|
84
|
+
...(claudeSandboxOn(repo) ? { enabled: true } : {}),
|
|
85
|
+
filesystem: { allowWrite: writableRoots() },
|
|
86
|
+
...(network
|
|
87
|
+
? {
|
|
88
|
+
network: { allowLocalBinding: true, ...(sock ? { allowUnixSockets: [sock] } : {}) },
|
|
89
|
+
excludedCommands: ["docker *"],
|
|
90
|
+
}
|
|
91
|
+
: // a boolean overrides the user's own `allowLocalBinding: true`; their array grants cannot be revoked here
|
|
92
|
+
{ network: { allowLocalBinding: false } }),
|
|
93
|
+
};
|
|
94
|
+
const args = [...base, "--settings", JSON.stringify({ sandbox })];
|
|
95
|
+
if (!network) {
|
|
96
|
+
const i = args.indexOf("--disallowedTools") + 1;
|
|
97
|
+
args[i] = [args[i], "WebFetch", "WebSearch"].join(",");
|
|
98
|
+
}
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `sandbox.enabled` in one settings file, when it says true or false; undefined when absent or unreadable. */
|
|
103
|
+
function sandboxEnabledIn(file: string): boolean | undefined {
|
|
104
|
+
try {
|
|
105
|
+
const on = JSON.parse(readFileSync(file, "utf8"))?.sandbox?.enabled;
|
|
106
|
+
return typeof on === "boolean" ? on : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether Claude Code's Bash sandbox is on for a worker in `repo` (`sandbox.enabled`): the most specific
|
|
114
|
+
* settings file that says wins, as Claude Code layers them: the user's settings.json, then the project's
|
|
115
|
+
* .claude/settings.json and .claude/settings.local.json (Claude Code has no user-level settings.local.json).
|
|
116
|
+
*/
|
|
117
|
+
export function claudeSandboxOn(repo: string = process.cwd()): boolean {
|
|
118
|
+
const files = [
|
|
119
|
+
join(claudeHome(), "settings.json"),
|
|
120
|
+
join(repo, ".claude", "settings.json"),
|
|
121
|
+
join(repo, ".claude", "settings.local.json"),
|
|
122
|
+
];
|
|
123
|
+
return files.map(sandboxEnabledIn).findLast((on) => on !== undefined) ?? false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Spec §5: with Claude Code's own sandbox off (its default) a headless worker's Bash is an unsandboxed
|
|
128
|
+
* shell, which doctor probes as is. With it on, only a model turn runs inside it, so doctor says what to
|
|
129
|
+
* check instead of spending one.
|
|
130
|
+
*/
|
|
131
|
+
async function accessShell(): Promise<AccessShell | string> {
|
|
132
|
+
if (claudeSandboxOn())
|
|
133
|
+
return "Claude Code's own sandbox is on (sandbox.enabled): catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there";
|
|
134
|
+
return scratchShell("an unsandboxed shell (Claude Code's sandbox is off)", []);
|
|
135
|
+
}
|
|
136
|
+
|
|
63
137
|
function plan(r: RunRequest): SpawnPlan {
|
|
64
138
|
if (r.thread !== null && !THREAD.test(r.thread))
|
|
65
139
|
throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
|
|
@@ -80,7 +154,7 @@ function plan(r: RunRequest): SpawnPlan {
|
|
|
80
154
|
...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
|
|
81
155
|
"--permission-prompts",
|
|
82
156
|
"none",
|
|
83
|
-
...
|
|
157
|
+
...claudeAccessArgs(r.access, r.network, r.repo),
|
|
84
158
|
// --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
|
|
85
159
|
...(r.isolated ? ["--safe-mode"] : []),
|
|
86
160
|
],
|
|
@@ -147,10 +221,29 @@ function finalize(run: FinishedRun): Outcome {
|
|
|
147
221
|
};
|
|
148
222
|
}
|
|
149
223
|
|
|
224
|
+
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
225
|
+
|
|
226
|
+
/** Spec §3.7: the main thread's last tool call (a command, a file edit, another tool) or its text. */
|
|
227
|
+
function claudeActivity(e: Record<string, any>): string | undefined {
|
|
228
|
+
if (e.type !== "assistant" || e.parent_tool_use_id) return undefined;
|
|
229
|
+
const c = (e.message?.content ?? []).at(-1);
|
|
230
|
+
if (c?.type === "tool_use") {
|
|
231
|
+
const input = c.input ?? {};
|
|
232
|
+
if (typeof input.command === "string") return `$ ${input.command}`;
|
|
233
|
+
if (typeof input.file_path === "string")
|
|
234
|
+
return `${EDIT_TOOLS.has(c.name) ? "edit" : String(c.name)} ${input.file_path}`;
|
|
235
|
+
return String(c.name ?? "tool");
|
|
236
|
+
}
|
|
237
|
+
if (c?.type === "text" && typeof c.text === "string") return c.text;
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
150
241
|
function parse(line: string): EventDelta {
|
|
151
242
|
const e = parseClaudeLine(line);
|
|
152
243
|
if (!e) return {};
|
|
153
244
|
const d: EventDelta = { lastEvent: eventName(e) };
|
|
245
|
+
const activity = claudeActivity(e);
|
|
246
|
+
if (activity) d.activity = activity;
|
|
154
247
|
if (typeof e.session_id === "string" && e.session_id) d.thread = e.session_id;
|
|
155
248
|
if (e.type === "system" && e.subtype === "api_retry") d.retrying = true;
|
|
156
249
|
if (e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") d.limit = true;
|
|
@@ -218,6 +311,7 @@ export const claudeCodeAdapter: BackendAdapter = {
|
|
|
218
311
|
enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
|
|
219
312
|
errors: { limit: CLAUDE_LIMIT, tooOld: CLAUDE_TOO_OLD },
|
|
220
313
|
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
314
|
+
accessShell,
|
|
221
315
|
// the `result` event is the last thing claude prints; a CLI still running 30 s later is stuck
|
|
222
316
|
graceAfterFinalMs: 30_000,
|
|
223
317
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { CatherdError } from "../../domain/errors.ts";
|
|
5
4
|
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
6
5
|
import {
|
|
6
|
+
type AccessShell,
|
|
7
7
|
type BackendAdapter,
|
|
8
8
|
compareVersions,
|
|
9
9
|
type DiscoveredModel,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type SpawnPlan,
|
|
16
16
|
} from "../backend.ts";
|
|
17
17
|
import { type CliResult, runCli } from "../cli.ts";
|
|
18
|
+
import { scratchShell, writableRoots } from "../access.ts";
|
|
18
19
|
import { CODEX_LIMIT, CODEX_TOO_OLD, foldCodexEvents, parseCodexLine } from "./events.ts";
|
|
19
20
|
|
|
20
21
|
/** The item types that are a tool call running, as the idle watchdog counts them. */
|
|
@@ -30,6 +31,43 @@ const SANDBOX: Record<Access, string> = {
|
|
|
30
31
|
full: "danger-full-access",
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The `writable_roots` the user's Codex config lists under its top-level `[sandbox_workspace_write]`
|
|
36
|
+
* (`$CODEX_HOME/config.toml`). A `-c` override replaces that array, so catherd passes the union. A
|
|
37
|
+
* `--profile`'s or a managed requirements file's roots are not read. Unreadable or absent: none.
|
|
38
|
+
*/
|
|
39
|
+
export function userWritableRoots(): string[] {
|
|
40
|
+
const file = join(userCodexHome(), "config.toml");
|
|
41
|
+
if (!existsSync(file)) return [];
|
|
42
|
+
try {
|
|
43
|
+
const roots = (Bun.TOML.parse(readFileSync(file, "utf8")) as Record<string, unknown>)
|
|
44
|
+
.sandbox_workspace_write as { writable_roots?: unknown } | undefined;
|
|
45
|
+
const list = roots?.writable_roots;
|
|
46
|
+
return Array.isArray(list) ? list.filter((r): r is string => typeof r === "string" && r !== "") : [];
|
|
47
|
+
} catch {
|
|
48
|
+
// a config Codex cannot parse either: Codex reports it on the run itself
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spec §5: a workspace-write worker also writes the lock and temp dirs, and (unless the role says
|
|
55
|
+
* `network: false`) reaches the network and binds loopback. `network_access` is always set, to false for
|
|
56
|
+
* `network: false`, since leaving it out would keep a `network_access = true` from the user's config.toml.
|
|
57
|
+
* The same `-c` overrides go to `codex sandbox` in doctor's probes, so doctor tests exactly what a worker gets. `-c …writable_roots=` replaces the
|
|
58
|
+
* user's own roots, so theirs go in too; an isolated run ignores the user's config, so it has none.
|
|
59
|
+
*/
|
|
60
|
+
export function codexGrants(access: Access, network = true, isolated = false): string[] {
|
|
61
|
+
if (access !== "workspace-write") return [];
|
|
62
|
+
const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots()])];
|
|
63
|
+
return [
|
|
64
|
+
"-c",
|
|
65
|
+
`sandbox_workspace_write.network_access=${network}`,
|
|
66
|
+
"-c",
|
|
67
|
+
`sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
33
71
|
/** How long a `codex` query (version, login, models) may take before it counts as failed. */
|
|
34
72
|
export const codexShell = { timeoutMs: 15_000 };
|
|
35
73
|
|
|
@@ -51,6 +89,7 @@ function plan(r: RunRequest): SpawnPlan {
|
|
|
51
89
|
"--json",
|
|
52
90
|
"-o",
|
|
53
91
|
r.replyPath,
|
|
92
|
+
...codexGrants(r.access, r.network, r.isolated),
|
|
54
93
|
];
|
|
55
94
|
const sandbox = SANDBOX[r.access];
|
|
56
95
|
const args =
|
|
@@ -169,29 +208,44 @@ async function listModels(): Promise<DiscoveredModel[]> {
|
|
|
169
208
|
}
|
|
170
209
|
|
|
171
210
|
/**
|
|
172
|
-
* Spec §
|
|
173
|
-
*
|
|
211
|
+
* Spec §12: `codex sandbox [-c …] -- <cmd>` in the workspace-write sandbox with the grants a worker gets
|
|
212
|
+
* (codexGrants), else the old `codex sandbox <os> --full-auto` form. Each form is tried with `true` first;
|
|
213
|
+
* a string says why neither runs here.
|
|
174
214
|
*/
|
|
175
|
-
async function
|
|
215
|
+
async function accessShell(o: { network: boolean }): Promise<AccessShell | string> {
|
|
216
|
+
const grants = codexGrants("workspace-write", o.network);
|
|
176
217
|
const os = process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : null;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} finally {
|
|
193
|
-
rmSync(cwd, { recursive: true, force: true });
|
|
218
|
+
const forms: [string, string[]][] = [
|
|
219
|
+
["codex sandbox", ["codex", "sandbox", "-c", "sandbox_mode=workspace-write", ...grants, "--"]],
|
|
220
|
+
...(os
|
|
221
|
+
? [
|
|
222
|
+
[
|
|
223
|
+
`codex sandbox ${os} --full-auto (the old form)`,
|
|
224
|
+
["codex", "sandbox", os, "--full-auto", ...grants, "--"],
|
|
225
|
+
],
|
|
226
|
+
]
|
|
227
|
+
: []),
|
|
228
|
+
] as [string, string[]][];
|
|
229
|
+
for (const [how, prefix] of forms) {
|
|
230
|
+
const shell = scratchShell(how, prefix);
|
|
231
|
+
if ((await shell.run("true", []))?.ok) return shell;
|
|
232
|
+
shell.close();
|
|
194
233
|
}
|
|
234
|
+
return "no codex sandbox to test with on this machine (codex sandbox did not run `true`)";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Spec §3.7: a command Codex runs, the files it changes, or its message. */
|
|
238
|
+
function codexActivity(e: Record<string, any>): string | undefined {
|
|
239
|
+
const it = e.item;
|
|
240
|
+
if (!it || (e.type !== "item.started" && e.type !== "item.completed")) return undefined;
|
|
241
|
+
if (it.type === "command_execution" && typeof it.command === "string") return `$ ${it.command}`;
|
|
242
|
+
if (it.type === "file_change" && Array.isArray(it.changes))
|
|
243
|
+
return `edit ${it.changes
|
|
244
|
+
.map((c: { path?: unknown }) => c.path)
|
|
245
|
+
.filter((p: unknown) => typeof p === "string")
|
|
246
|
+
.join(", ")}`;
|
|
247
|
+
if (it.type === "agent_message" && typeof it.text === "string") return it.text;
|
|
248
|
+
return undefined;
|
|
195
249
|
}
|
|
196
250
|
|
|
197
251
|
export const codexAdapter: BackendAdapter = {
|
|
@@ -213,6 +267,7 @@ export const codexAdapter: BackendAdapter = {
|
|
|
213
267
|
...(id !== null && open !== null ? { item: { id, open } } : {}),
|
|
214
268
|
...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
|
|
215
269
|
lastEvent: f.lastEvent ?? undefined,
|
|
270
|
+
...(codexActivity(e) ? { activity: codexActivity(e) } : {}),
|
|
216
271
|
...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
|
|
217
272
|
...(f.limit ? { limit: true } : {}),
|
|
218
273
|
...(f.tooOld ? { tooOld: true } : {}),
|
|
@@ -223,5 +278,5 @@ export const codexAdapter: BackendAdapter = {
|
|
|
223
278
|
errors: { limit: CODEX_LIMIT, tooOld: CODEX_TOO_OLD },
|
|
224
279
|
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
225
280
|
graceAfterFinalMs: null,
|
|
226
|
-
|
|
281
|
+
accessShell,
|
|
227
282
|
};
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type SpawnPlan,
|
|
18
18
|
type Spent,
|
|
19
19
|
} from "../backend.ts";
|
|
20
|
+
import { scratchShell } from "../access.ts";
|
|
20
21
|
import { jsonOf, runCli } from "../cli.ts";
|
|
21
22
|
import { configDir } from "../../infra/paths.ts";
|
|
22
23
|
import { discovered, readDiscovery } from "../discovery.ts";
|
|
@@ -181,10 +182,24 @@ function finalize(run: FinishedRun): Outcome {
|
|
|
181
182
|
};
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
/** Spec §3.7: the tool opencode ran (a command, a file edit, another tool with its first argument) or its text. */
|
|
186
|
+
function opencodeActivity(e: Record<string, any>): string | undefined {
|
|
187
|
+
if (e.type === "text" && typeof e.part?.text === "string") return e.part.text;
|
|
188
|
+
if (e.type !== "tool_use" || typeof e.part?.tool !== "string") return undefined;
|
|
189
|
+
const input = e.part.state?.input ?? {};
|
|
190
|
+
if (typeof input.command === "string") return `$ ${input.command}`;
|
|
191
|
+
const path = input.filePath ?? input.path;
|
|
192
|
+
if (/^(edit|write|patch)$/.test(e.part.tool) && typeof path === "string") return `edit ${path}`;
|
|
193
|
+
const first = Object.values(input).find((v) => typeof v === "string");
|
|
194
|
+
return `${e.part.tool}${typeof first === "string" ? ` ${first}` : ""}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
184
197
|
function parse(line: string): EventDelta {
|
|
185
198
|
const e = parseOpencodeLine(line);
|
|
186
199
|
if (!e) return {};
|
|
187
200
|
const d: EventDelta = { lastEvent: eventName(e) };
|
|
201
|
+
const activity = opencodeActivity(e);
|
|
202
|
+
if (activity) d.activity = activity;
|
|
188
203
|
if (typeof e.sessionID === "string") d.thread = e.sessionID;
|
|
189
204
|
if (e.type === "step_finish") {
|
|
190
205
|
d.tokens = opencodeTokens(e.part?.tokens);
|
|
@@ -313,4 +328,6 @@ export const opencodeAdapter: BackendAdapter = {
|
|
|
313
328
|
isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
|
|
314
329
|
failoverFor,
|
|
315
330
|
graceAfterFinalMs: null,
|
|
331
|
+
// research 2026-09-25 §2.6: opencode has no OS sandbox; its shell runs with the user's own authority
|
|
332
|
+
accessShell: async () => scratchShell("an unsandboxed shell (opencode has no sandbox)", []),
|
|
316
333
|
};
|
package/src/domain/errors.ts
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { billingKeyOf, type Catalog, DIMS, type Dim, rungInfo, scoresOf } from "./catalog.ts";
|
|
2
|
+
import { type BillingMode, compareCost, type Cost, costOf, DEFAULT_BILLING } from "./cost.ts";
|
|
3
|
+
import { type Rung, tryParseRung } from "./ids.ts";
|
|
4
|
+
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
|
|
8
|
+
* quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
|
|
9
|
+
*/
|
|
10
|
+
export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
|
|
11
|
+
|
|
12
|
+
/** Whether a rung draws on the Claude plan (native `claude:` or headless `claude-code:`). */
|
|
13
|
+
export function claudeBilled(rung: string): boolean {
|
|
14
|
+
const r = tryParseRung(rung);
|
|
15
|
+
return r !== null && quotaOf(r) === "claude-code";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A rung's scores by dim, its own or borrowed through a treat-like; null when unscored or not a rung. */
|
|
19
|
+
export function valuesOf(c: Catalog, rung: string): Partial<Record<Dim, number>> | null {
|
|
20
|
+
try {
|
|
21
|
+
return scoresOf(c, rungInfo(c, rung).canonical)?.values ?? null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The dims used by the routing bars (kind × difficulty) the rung clears: what makes it fit its lanes. */
|
|
28
|
+
export function barDims(c: Catalog, rung: string): Dim[] {
|
|
29
|
+
const v = valuesOf(c, rung);
|
|
30
|
+
if (!v) return [];
|
|
31
|
+
const used = new Set<Dim>();
|
|
32
|
+
for (const kind of KINDS)
|
|
33
|
+
for (const d of DIFFICULTIES) {
|
|
34
|
+
const bar = Object.entries(c.bars[kind][d]).filter(([, min]) => min !== undefined) as [Dim, number][];
|
|
35
|
+
if (bar.every(([dim, min]) => (v[dim] ?? Number.NEGATIVE_INFINITY) >= min))
|
|
36
|
+
for (const [dim] of bar) used.add(dim);
|
|
37
|
+
}
|
|
38
|
+
return DIMS.filter((d) => used.has(d));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Spec 1.1 §11: the dims, among those the rung's bars use, on which `standIn` scores below `rung` (a
|
|
43
|
+
* missing score counts as below). Empty means the stand-in clears every bar the rung clears, at least as
|
|
44
|
+
* well: no downgrade.
|
|
45
|
+
*/
|
|
46
|
+
export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
|
|
47
|
+
const a = valuesOf(c, rung);
|
|
48
|
+
if (!a) return [];
|
|
49
|
+
const b = valuesOf(c, standIn) ?? {};
|
|
50
|
+
return barDims(c, rung).filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spec 1.1 §11 "the ladder goes down": the dims both rungs are scored on where `upper` (the later rung)
|
|
55
|
+
* scores below `lower`, when it scores above it on none of them. Empty when it does not go down.
|
|
56
|
+
*/
|
|
57
|
+
export function ladderDropDims(c: Catalog, lower: string, upper: string): Dim[] {
|
|
58
|
+
const a = valuesOf(c, lower);
|
|
59
|
+
const b = valuesOf(c, upper);
|
|
60
|
+
if (!a || !b) return [];
|
|
61
|
+
const shared = DIMS.filter((d) => a[d] !== undefined && b[d] !== undefined);
|
|
62
|
+
if (shared.some((d) => (b[d] as number) > (a[d] as number))) return [];
|
|
63
|
+
return shared.filter((d) => (b[d] as number) < (a[d] as number));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const costFor = (c: Catalog, billing: Partial<Record<string, BillingMode>>, rung: string): Cost => {
|
|
67
|
+
const info = rungInfo(c, rung);
|
|
68
|
+
return costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Spec 1.1 §11: the rungs of `pool` that can stand in for `rung`, best first. A stand-in is on another
|
|
73
|
+
* quota, scored, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
|
|
74
|
+
* to), startable by `dispatch` (not a native `claude:` subagent), and no downgrade on the rung's bar dims.
|
|
75
|
+
* Claude-billed stand-ins rank last, then the cheapest first.
|
|
76
|
+
*/
|
|
77
|
+
export function rankStandIns(
|
|
78
|
+
c: Catalog,
|
|
79
|
+
billing: Partial<Record<string, BillingMode>>,
|
|
80
|
+
rung: string,
|
|
81
|
+
pool: readonly string[],
|
|
82
|
+
): string[] {
|
|
83
|
+
const from = tryParseRung(rung);
|
|
84
|
+
if (!from) return [];
|
|
85
|
+
const fits = [...new Set(pool)].filter((x) => {
|
|
86
|
+
const r = tryParseRung(x);
|
|
87
|
+
if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !valuesOf(c, x)) return false;
|
|
88
|
+
if (costFor(c, billing, x).tier === 1) return false;
|
|
89
|
+
return downgradeDims(c, rung, x).length === 0;
|
|
90
|
+
});
|
|
91
|
+
return fits.sort(
|
|
92
|
+
(a, b) =>
|
|
93
|
+
Number(claudeBilled(a)) - Number(claudeBilled(b)) ||
|
|
94
|
+
compareCost(costFor(c, billing, a), costFor(c, billing, b)) ||
|
|
95
|
+
a.localeCompare(b),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** How a family's `on` key or a model id's prefix maps to the backend that runs it. */
|
|
100
|
+
const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every rung the catalog knows how to name: each shipped family's model on each backend at each effort,
|
|
104
|
+
* each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
|
|
105
|
+
* `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
|
|
106
|
+
*/
|
|
107
|
+
export function catalogRungs(c: Catalog): string[] {
|
|
108
|
+
const out = new Set<string>();
|
|
109
|
+
for (const f of c.families)
|
|
110
|
+
for (const [key, m] of Object.entries(f.on)) {
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
for (const e of m.efforts) out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
|
|
113
|
+
}
|
|
114
|
+
for (const [backend, l] of Object.entries(c.listed))
|
|
115
|
+
for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
|
|
116
|
+
for (const canonical of Object.keys(c.treatLike))
|
|
117
|
+
if (/^opencode(-go)?\//.test(canonical)) out.add(`opencode:${canonical}`);
|
|
118
|
+
return [...out].sort();
|
|
119
|
+
}
|
package/src/domain/lane.ts
CHANGED
|
@@ -60,6 +60,29 @@ export function parseLaneHeader(text: string): LaneHeader {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The fix every lane-header refusal carries: the values the catalog knows. */
|
|
64
|
+
export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Spec 1.1 §6: a lane's `Kind:` and `Difficulty:` must be values the catalog knows, so routing never falls
|
|
68
|
+
* back on a typo. `where` names the lane file in the message. Throws E_LANE_INVALID; returns the header.
|
|
69
|
+
*/
|
|
70
|
+
export function assertLaneHeader(text: string, where: string): LaneHeader {
|
|
71
|
+
const h = parseLaneHeader(text);
|
|
72
|
+
const problems: string[] = [];
|
|
73
|
+
const check = (label: string, value: string | null, ok: boolean) => {
|
|
74
|
+
if (ok) return;
|
|
75
|
+
problems.push(
|
|
76
|
+
value === null ? `no ${label}: line` : `${label} "${unquote(value)}" is not one the catalog knows`,
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
check("Kind", field(text, "kind"), h.kind !== null);
|
|
80
|
+
check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
|
|
81
|
+
if (problems.length)
|
|
82
|
+
throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
|
|
83
|
+
return h;
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
const bare = (p: string) => p.replace(/\/+$/, "");
|
|
64
87
|
const covers = (outer: string, inner: string) => inner === outer || inner.startsWith(`${outer}/`);
|
|
65
88
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec §3.5: the message catherd pushes to the main thread when roles finish or need attention. Pure text; the
|
|
3
|
+
* notifier (services/notifier.ts) gathers the facts and infra/peer-inbox.ts sends the result.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Spec §3.6: `later` waits for the receiver's turn to end, `next` drains at its next tool round. Never `now`. */
|
|
7
|
+
export type NoticePriority = "later" | "next";
|
|
8
|
+
|
|
9
|
+
/** One role's news: it finished (`record`), or an event while it runs (`stall`). */
|
|
10
|
+
export interface Notice {
|
|
11
|
+
kind: "finished" | "stalled";
|
|
12
|
+
runId: string;
|
|
13
|
+
runTitle: string;
|
|
14
|
+
dispatchId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
role: string;
|
|
17
|
+
/** the lane it worked, when it had one: the multi-role preview names it */
|
|
18
|
+
lane: string | null;
|
|
19
|
+
rung: string;
|
|
20
|
+
/** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
|
|
21
|
+
status: string;
|
|
22
|
+
/** the reply's STATUS word, null without a STATUS line */
|
|
23
|
+
replyStatus: string | null;
|
|
24
|
+
secs: number;
|
|
25
|
+
changedOwned: number;
|
|
26
|
+
/** the worker's reply (finished notices), capped when the message is written */
|
|
27
|
+
reply: string;
|
|
28
|
+
priority: NoticePriority;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The body of a reply a message carries at most (spec §3.5). */
|
|
32
|
+
export const REPLY_CAP = 2_000;
|
|
33
|
+
|
|
34
|
+
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
35
|
+
|
|
36
|
+
/** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
|
|
37
|
+
export function noticeHeader(n: Notice): string {
|
|
38
|
+
const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
|
|
39
|
+
if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
|
|
40
|
+
return [
|
|
41
|
+
head,
|
|
42
|
+
n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
|
|
43
|
+
`${n.secs}s`,
|
|
44
|
+
plural(n.changedOwned, "owned file changed", "owned files changed"),
|
|
45
|
+
].join(" · ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The reply, at most `cap` characters, cut on a line boundary, saying where the rest is. */
|
|
49
|
+
export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
|
|
50
|
+
const text = reply.trimEnd();
|
|
51
|
+
if (text.length <= cap) return text;
|
|
52
|
+
const cut = text.slice(0, cap);
|
|
53
|
+
const at = cut.lastIndexOf("\n");
|
|
54
|
+
return `${at > 0 ? cut.slice(0, at) : cut}\n…(cut; result(run, name) has the rest)`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The envelope's own tags are neutralised in a body, in any case (a parser may not care), so a reply can never
|
|
59
|
+
* close the envelope early.
|
|
60
|
+
*/
|
|
61
|
+
const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
|
|
62
|
+
|
|
63
|
+
function block(n: Notice): string {
|
|
64
|
+
const call = `run: "${n.runId}", name: "${n.name}"`;
|
|
65
|
+
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
|
|
66
|
+
const reply = capNoticeReply(n.reply);
|
|
67
|
+
return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
|
|
71
|
+
function previewOf(ns: Notice[]): string {
|
|
72
|
+
const titles = [...new Set(ns.map((n) => n.runTitle))];
|
|
73
|
+
const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
|
|
74
|
+
const what = ns.every((n) => n.kind === "finished")
|
|
75
|
+
? `${ns.length} roles finished`
|
|
76
|
+
: `${plural(ns.length, "role", "roles")} to look at`;
|
|
77
|
+
const each = ns.map(
|
|
78
|
+
(n) => `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]}`,
|
|
79
|
+
);
|
|
80
|
+
return `catherd · ${where} · ${what}: ${each.join(", ")}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The message body for one or more notices: one block per role, a preview line first when there are several. */
|
|
84
|
+
export function formatNotices(ns: Notice[]): string {
|
|
85
|
+
if (ns.length === 1) return neutral(block(ns[0] as Notice));
|
|
86
|
+
return neutral([previewOf(ns), ...ns.map(block)].join("\n\n"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The message's priority: the most urgent of its notices. */
|
|
90
|
+
export const priorityOf = (ns: Notice[]): NoticePriority =>
|
|
91
|
+
ns.some((n) => n.priority === "next") ? "next" : "later";
|
|
92
|
+
|
|
93
|
+
/** The envelope catherd sends: from-name only, no `from` (the MCP server has no reply address). */
|
|
94
|
+
export const envelope = (body: string): string =>
|
|
95
|
+
`<cross-session-message from-name="catherd">\n${body}\n</cross-session-message>`;
|