omp-multi-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +351 -0
  3. package/package.json +76 -0
  4. package/scripts/cli.ts +164 -0
  5. package/scripts/setup/claude.ts +41 -0
  6. package/scripts/setup/codex.ts +35 -0
  7. package/scripts/setup/omp.ts +167 -0
  8. package/scripts/setup/toolchain.ts +81 -0
  9. package/scripts/setup/types.ts +76 -0
  10. package/scripts/setup.ts +116 -0
  11. package/src/agents/availability.ts +106 -0
  12. package/src/agents/claude-events.ts +125 -0
  13. package/src/agents/claude.ts +226 -0
  14. package/src/agents/codex-events.ts +149 -0
  15. package/src/agents/codex.ts +236 -0
  16. package/src/agents/types.ts +81 -0
  17. package/src/commands/agents.ts +140 -0
  18. package/src/commands/delegate-command.ts +159 -0
  19. package/src/commands/harness-setup.ts +94 -0
  20. package/src/commands/sessions.ts +394 -0
  21. package/src/config/load.ts +78 -0
  22. package/src/config/schema.ts +249 -0
  23. package/src/index.ts +129 -0
  24. package/src/process/executable.ts +49 -0
  25. package/src/process/jsonl.ts +124 -0
  26. package/src/process/process-error.ts +178 -0
  27. package/src/process/redact.ts +120 -0
  28. package/src/process/spawn-agent.ts +218 -0
  29. package/src/routing/handoff.ts +59 -0
  30. package/src/routing/prompt.ts +72 -0
  31. package/src/routing/route.ts +286 -0
  32. package/src/runs/lock.ts +158 -0
  33. package/src/runs/registry.ts +379 -0
  34. package/src/runs/ring-buffer.ts +81 -0
  35. package/src/runs/types.ts +141 -0
  36. package/src/sessions/resume.ts +163 -0
  37. package/src/sessions/store.ts +273 -0
  38. package/src/tools/agent-runs.ts +169 -0
  39. package/src/tools/ask-agent.ts +230 -0
  40. package/src/tools/delegate.ts +196 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Claude Code adapter. Every Claude flag in this project lives in this file.
3
+ * Verified against Claude Code 2.1.274 (_spec/04-claude-adapter.md).
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import type { AgentConfig } from "../config/schema.ts";
7
+ import { JsonlReader } from "../process/jsonl.ts";
8
+ import {
9
+ AgentError,
10
+ authRequired,
11
+ cancelled,
12
+ invalidCwd,
13
+ invalidOutput,
14
+ looksLikeAuthFailure,
15
+ processFailed,
16
+ providerLimit,
17
+ timedOut,
18
+ } from "../process/process-error.ts";
19
+ import { resolveExecutable } from "../process/executable.ts";
20
+ import { SpawnCwdError, spawnAgent } from "../process/spawn-agent.ts";
21
+ import { detect } from "./availability.ts";
22
+ import { isQuotaFailure } from "./codex-events.ts";
23
+ import { applyClaudeEvent, newClaudeStreamState } from "./claude-events.ts";
24
+ import { MODE_DEFAULT_READ_ONLY, type AgentRequest, type AgentResult, type AgentRunOptions, type ExternalAgent } from "./types.ts";
25
+
26
+ /** Tools a read-only run may use. Anything that writes is simply absent. */
27
+ export const READ_ONLY_TOOLS = ["Read", "Grep", "Glob"] as const;
28
+
29
+ export interface ClaudeCapabilities {
30
+ supportsStreamJson: boolean;
31
+ requiresVerboseWithStreamJson: boolean;
32
+ supportsSessionId: boolean;
33
+ supportsPermissionMode: boolean;
34
+ }
35
+
36
+ /** Unknown or newer versions get the newest known capability set. */
37
+ export function claudeCapabilities(_version: string | undefined): ClaudeCapabilities {
38
+ return {
39
+ supportsStreamJson: true,
40
+ // Verified on 2.1.274: stream-json under -p needs --verbose.
41
+ requiresVerboseWithStreamJson: true,
42
+ supportsSessionId: true,
43
+ supportsPermissionMode: true,
44
+ };
45
+ }
46
+
47
+ export interface BuildClaudeArgsInput {
48
+ request: AgentRequest;
49
+ config: AgentConfig;
50
+ capabilities: ClaudeCapabilities;
51
+ readOnly: boolean;
52
+ /** Caller-generated UUID used for a new session. */
53
+ sessionId: string;
54
+ /** Branch instead of continuing when resuming. */
55
+ fork?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Build argv for `claude -p`. Two things are deliberately absent:
60
+ * the prompt (stdin) and the working directory (Claude Code has no -C; the spawn cwd
61
+ * carries it).
62
+ */
63
+ export function buildClaudeArgs(input: BuildClaudeArgsInput): string[] {
64
+ const { request, config, capabilities, readOnly, sessionId, fork } = input;
65
+ const args: string[] = ["-p"];
66
+
67
+ if (capabilities.supportsStreamJson) {
68
+ args.push("--output-format", "stream-json");
69
+ if (capabilities.requiresVerboseWithStreamJson) args.push("--verbose");
70
+ } else {
71
+ args.push("--output-format", "json");
72
+ }
73
+
74
+ if (request.sessionId) {
75
+ args.push("--resume", request.sessionId);
76
+ if (fork) args.push("--fork-session");
77
+ } else if (capabilities.supportsSessionId) {
78
+ // Generating the id ourselves makes the OMP↔worker mapping known before the process
79
+ // starts, and survives a crash mid-run.
80
+ args.push("--session-id", sessionId);
81
+ }
82
+
83
+ if (readOnly && capabilities.supportsPermissionMode) {
84
+ args.push("--permission-mode", "plan");
85
+ args.push("--tools", READ_ONLY_TOOLS.join(","));
86
+ } else if (!readOnly && config.acceptEdits && capabilities.supportsPermissionMode) {
87
+ args.push("--permission-mode", "acceptEdits");
88
+ }
89
+ // else: readOnly was requested but this capability set cannot enforce it — no flag is
90
+ // emitted, and claudeReadOnlyEnforcement() below will honestly report `false` for it.
91
+
92
+ const model = request.model ?? config.model;
93
+ if (model) args.push("--model", model);
94
+
95
+ for (const dir of config.additionalDirs) args.push("--add-dir", dir);
96
+
97
+ return args;
98
+ }
99
+
100
+ /**
101
+ * Honest read-only enforcement (T-602): derived from the argv we actually built, never
102
+ * from what was merely requested. `--permission-mode plan` plus a read-only `--tools`
103
+ * allowlist is real enforcement — Claude cannot invoke a tool outside the allowlist,
104
+ * confirmed via `claude --help` (both flags exist exactly as spelled here on 2.1.277). If
105
+ * `capabilities.supportsPermissionMode` was false, neither flag was emitted and this
106
+ * correctly reports `false`.
107
+ */
108
+ export function claudeReadOnlyEnforcement(args: string[], readOnly: boolean): { enforced: boolean; mechanism?: string } {
109
+ if (!readOnly) return { enforced: false };
110
+ const modeIdx = args.indexOf("--permission-mode");
111
+ const hasPlanMode = modeIdx !== -1 && args[modeIdx + 1] === "plan";
112
+ const hasToolsAllowlist = args.includes("--tools");
113
+ const enforced = hasPlanMode && hasToolsAllowlist;
114
+ return enforced ? { enforced: true, mechanism: `--permission-mode plan --tools ${READ_ONLY_TOOLS.join(",")}` } : { enforced: false };
115
+ }
116
+
117
+ export class ClaudeAgent implements ExternalAgent {
118
+ readonly name = "claude" as const;
119
+
120
+ constructor(private readonly config: AgentConfig) {}
121
+
122
+ async isAvailable(force = false) {
123
+ return detect("claude", this.config, { cwd: process.cwd(), force });
124
+ }
125
+
126
+ async run(request: AgentRequest, options: AgentRunOptions): Promise<AgentResult> {
127
+ const started = Date.now();
128
+ const executable = resolveExecutable(this.config.executable);
129
+ if (!executable) {
130
+ throw new AgentError({
131
+ code: "EXECUTABLE_NOT_FOUND",
132
+ agent: "claude",
133
+ message: `\`${this.config.executable}\` was not found on PATH. Install Claude Code, or set multiHarness.claude.executable to its full path.`,
134
+ });
135
+ }
136
+
137
+ const availability = await detect("claude", this.config, { cwd: request.cwd });
138
+ const capabilities = claudeCapabilities(availability.version);
139
+
140
+ const readOnly = request.readOnly ?? (request.mode ? MODE_DEFAULT_READ_ONLY[request.mode] : false);
141
+ const timeoutMs = request.timeoutMs ?? this.config.timeoutMs;
142
+ const sessionId = randomUUID();
143
+
144
+ const args = buildClaudeArgs({ request, config: this.config, capabilities, readOnly, sessionId, fork: request.fork });
145
+
146
+ const state = newClaudeStreamState();
147
+ const reader = new JsonlReader((value) => {
148
+ if (value === undefined) return;
149
+ const progress = applyClaudeEvent(state, value);
150
+ if (progress) options.onProgress?.(progress);
151
+ });
152
+
153
+ try {
154
+ options.onProgress?.({ phase: "starting" });
155
+
156
+ const result = await spawnAgent({
157
+ command: executable,
158
+ args,
159
+ cwd: request.cwd,
160
+ stdin: request.context ? `${request.context}\n\n${request.task}` : request.task,
161
+ timeoutMs,
162
+ signal: options.signal,
163
+ onStdout: (chunk) => reader.push(chunk),
164
+ });
165
+ reader.end();
166
+
167
+ if (result.cancelled) throw cancelled("claude");
168
+ if (result.timedOut) throw timedOut("claude", timeoutMs);
169
+
170
+ const failure = state.failure;
171
+ if (failure) {
172
+ if (isQuotaFailure(failure)) throw providerLimit("claude", failure);
173
+ if (looksLikeAuthFailure(failure)) throw authRequired("claude", failure);
174
+ throw processFailed("claude", result.exitCode, failure);
175
+ }
176
+
177
+ if (result.exitCode !== 0) {
178
+ const tail = result.stderr.trim().split("\n").slice(-3).join("\n");
179
+ if (looksLikeAuthFailure(result.stderr)) throw authRequired("claude", tail);
180
+ throw processFailed("claude", result.exitCode, tail);
181
+ }
182
+
183
+ // `??` is wrong here: a success result can carry an empty string, and that must
184
+ // fall through to the streamed assistant text rather than count as an answer.
185
+ const output = state.result?.trim() || state.lastAssistantText?.trim() || "";
186
+ if (!output) {
187
+ throw invalidOutput(
188
+ "claude",
189
+ state.sawResult
190
+ ? "the result event carried no text"
191
+ : `no result event was produced (${reader.stats.lines} output lines)`,
192
+ );
193
+ }
194
+
195
+ // The CLI is the authority on its own session id; ours was only a request.
196
+ const effectiveSessionId = state.sessionId ?? sessionId;
197
+ const enforcement = claudeReadOnlyEnforcement(args, readOnly);
198
+
199
+ return {
200
+ agent: "claude",
201
+ success: true,
202
+ output,
203
+ sessionId: effectiveSessionId,
204
+ exitCode: result.exitCode,
205
+ durationMs: Date.now() - started,
206
+ metadata: {
207
+ readOnlyEnforced: enforcement.enforced,
208
+ readOnlyMechanism: enforcement.mechanism,
209
+ cliVersion: availability.version,
210
+ turns: state.turns,
211
+ costUsd: state.costUsd,
212
+ permissionDenials: state.permissionDenials,
213
+ parseErrors: reader.stats.parseErrors,
214
+ model: request.model ?? this.config.model ?? null,
215
+ sessionIdMismatch: state.sessionId !== undefined && state.sessionId !== sessionId && !request.sessionId,
216
+ // `--fork-session` only applies when resuming; report the request honestly
217
+ // either way (see buildClaudeArgs / types.ts AgentRequest.fork).
218
+ forked: Boolean(request.fork),
219
+ },
220
+ };
221
+ } catch (e) {
222
+ if (e instanceof SpawnCwdError) throw invalidCwd("claude", request.cwd, e.message);
223
+ throw e;
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Interpretation of `codex exec --json` output.
3
+ *
4
+ * Event shapes captured from codex-cli 0.155.0 (2026-09-18):
5
+ * {"type":"thread.started","thread_id":"01a0…"}
6
+ * {"type":"turn.started"}
7
+ * {"type":"item.completed","item":{"id":"item_0","type":"error","message":"…"}}
8
+ * {"type":"error","message":"…"}
9
+ * {"type":"turn.failed","error":{"message":"…"}}
10
+ *
11
+ * Shapes drift between versions, so nothing here switches on an exhaustive list of event
12
+ * names: keys are searched from a candidate list and anything unrecognized is ignored
13
+ * (_spec/03-codex-adapter.md).
14
+ */
15
+ export interface CodexStreamState {
16
+ sessionId?: string;
17
+ /** Last assistant text seen, used only if the -o file is empty. */
18
+ lastAgentMessage?: string;
19
+ /** Terminal failure reported by the stream itself, even when the exit code is 0. */
20
+ failure?: string;
21
+ turnCompleted: boolean;
22
+ itemCount: number;
23
+ /**
24
+ * Set once a terminal event (`turn.completed`, `turn.failed`, or top-level `error`) has
25
+ * been recorded. A real stream carries at most one of these; a duplicate or out-of-order
26
+ * repeat (garbled stream, buggy CLI) must not flip an already-decided outcome — losing a
27
+ * real success to a stray late failure event would be worse than ignoring the duplicate.
28
+ */
29
+ settled: boolean;
30
+ }
31
+
32
+ export interface CodexProgressEvent {
33
+ phase: string;
34
+ detail?: string;
35
+ raw?: string;
36
+ }
37
+
38
+ function asRecord(value: unknown): Record<string, unknown> | null {
39
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
40
+ }
41
+
42
+ function firstString(source: Record<string, unknown>, keys: string[]): string | undefined {
43
+ for (const key of keys) {
44
+ const v = source[key];
45
+ if (typeof v === "string" && v.length > 0) return v;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ const SESSION_KEYS = ["thread_id", "session_id", "conversation_id", "threadId", "sessionId"];
51
+ const TEXT_KEYS = ["text", "message", "content", "last_agent_message"];
52
+
53
+ /** Human-friendly phase for an item type. Unknown types fall back to the raw type. */
54
+ function phaseForItem(itemType: string): string {
55
+ switch (itemType) {
56
+ case "agent_message":
57
+ case "assistant_message":
58
+ return "writing response";
59
+ case "command_execution":
60
+ case "local_shell_call":
61
+ return "running a command";
62
+ case "file_change":
63
+ case "patch_apply":
64
+ return "editing files";
65
+ case "reasoning":
66
+ return "thinking";
67
+ case "mcp_tool_call":
68
+ return "calling a tool";
69
+ case "web_search":
70
+ return "searching the web";
71
+ case "error":
72
+ return "reported a problem";
73
+ default:
74
+ return itemType.replace(/_/g, " ");
75
+ }
76
+ }
77
+
78
+ /** Feed one parsed event; mutates state and optionally yields a progress update. */
79
+ export function applyCodexEvent(state: CodexStreamState, value: unknown): CodexProgressEvent | null {
80
+ const event = asRecord(value);
81
+ if (!event) return null;
82
+
83
+ const type = typeof event.type === "string" ? event.type : "";
84
+
85
+ // Session id can appear on any event; take the first one we see.
86
+ if (!state.sessionId) {
87
+ const direct = firstString(event, SESSION_KEYS);
88
+ const nested = asRecord(event.thread) ?? asRecord(event.session) ?? asRecord(event.conversation);
89
+ state.sessionId = direct ?? (nested ? firstString(nested, [...SESSION_KEYS, "id"]) : undefined);
90
+ }
91
+
92
+ if (type === "thread.started") return { phase: "starting", raw: type };
93
+ if (type === "turn.started") return { phase: "working", raw: type };
94
+
95
+ if (type === "turn.completed") {
96
+ if (!state.settled) {
97
+ state.turnCompleted = true;
98
+ state.settled = true;
99
+ }
100
+ return { phase: "completed", raw: type };
101
+ }
102
+
103
+ if (type === "turn.failed") {
104
+ const error = asRecord(event.error);
105
+ const message = (error ? firstString(error, ["message", "detail"]) : undefined) ?? "the turn failed";
106
+ if (!state.settled) {
107
+ state.failure = message;
108
+ state.settled = true;
109
+ }
110
+ return { phase: "failed", detail: state.failure ?? message, raw: type };
111
+ }
112
+
113
+ if (type === "error") {
114
+ const message = firstString(event, ["message", "detail"]) ?? "unknown error";
115
+ if (!state.settled) {
116
+ state.failure = message;
117
+ state.settled = true;
118
+ }
119
+ return { phase: "failed", detail: state.failure ?? message, raw: type };
120
+ }
121
+
122
+ if (type === "item.completed" || type === "item.started" || type === "item.updated") {
123
+ const item = asRecord(event.item);
124
+ if (!item) return null;
125
+ const itemType = typeof item.type === "string" ? item.type : "item";
126
+ if (type === "item.completed") state.itemCount++;
127
+
128
+ if (itemType === "agent_message" || itemType === "assistant_message") {
129
+ const text = firstString(item, TEXT_KEYS);
130
+ if (text) state.lastAgentMessage = text;
131
+ }
132
+
133
+ // An `error` item is informational (e.g. a truncated skill description), not fatal —
134
+ // only top-level `error` / `turn.failed` end the run.
135
+ const detail = itemType === "command_execution" ? firstString(item, ["command", "cmd"]) : undefined;
136
+ return { phase: phaseForItem(itemType), detail, raw: `${type}:${itemType}` };
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ export function newCodexStreamState(): CodexStreamState {
143
+ return { turnCompleted: false, itemCount: 0, settled: false };
144
+ }
145
+
146
+ /** Provider-side limits are worth their own message — refilling is the fix, not retrying. */
147
+ export function isQuotaFailure(message: string): boolean {
148
+ return /out of credits|quota|rate.?limit|billing|insufficient.*(credit|balance)|usage limit/i.test(message);
149
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Codex CLI adapter. Every Codex flag in this project lives in this file.
3
+ * Verified against codex-cli 0.155.0 (_spec/03-codex-adapter.md).
4
+ */
5
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { AgentConfig } from "../config/schema.ts";
9
+ import { JsonlReader } from "../process/jsonl.ts";
10
+ import {
11
+ AgentError,
12
+ authRequired,
13
+ cancelled,
14
+ invalidCwd,
15
+ invalidOutput,
16
+ looksLikeAuthFailure,
17
+ processFailed,
18
+ providerLimit,
19
+ timedOut,
20
+ } from "../process/process-error.ts";
21
+ import { resolveExecutable } from "../process/executable.ts";
22
+ import { SpawnCwdError, spawnAgent } from "../process/spawn-agent.ts";
23
+ import { detect } from "./availability.ts";
24
+ import { applyCodexEvent, isQuotaFailure, newCodexStreamState } from "./codex-events.ts";
25
+ import { MODE_DEFAULT_READ_ONLY, type AgentRequest, type AgentResult, type AgentRunOptions, type ExternalAgent } from "./types.ts";
26
+
27
+ export interface CodexCapabilities {
28
+ supportsJson: boolean;
29
+ supportsOutputLastMessage: boolean;
30
+ supportsResumeSubcommand: boolean;
31
+ /**
32
+ * Whether this codex build honors `-s <mode>` at all. Verified present on 0.155.0
33
+ * (`codex exec --help` lists `-s, --sandbox <SANDBOX_MODE>`). Kept as a capability
34
+ * (rather than assumed) so an adapter talking to a codex build that dropped or renamed
35
+ * the flag can still report `readOnlyEnforced: false` truthfully instead of guessing.
36
+ */
37
+ supportsSandboxMode: boolean;
38
+ }
39
+
40
+ /** Unknown or newer versions get the newest known capability set. */
41
+ export function codexCapabilities(_version: string | undefined): CodexCapabilities {
42
+ return { supportsJson: true, supportsOutputLastMessage: true, supportsResumeSubcommand: true, supportsSandboxMode: true };
43
+ }
44
+
45
+ export interface BuildCodexArgsInput {
46
+ request: AgentRequest;
47
+ config: AgentConfig;
48
+ capabilities: CodexCapabilities;
49
+ /** Path for `-o`; omitted when the capability is unavailable. */
50
+ lastMessageFile?: string;
51
+ /** True when cwd is not inside a git repository. */
52
+ skipGitRepoCheck?: boolean;
53
+ readOnly: boolean;
54
+ /**
55
+ * Branch instead of continuing a resumed session. Codex has no `--fork-session`
56
+ * equivalent (verified via `codex exec --help`), so a forked run simply omits the
57
+ * `resume <id>` subcommand and starts fresh — see `AgentRequest.fork` in types.ts.
58
+ */
59
+ fork?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Build argv for `codex exec`. The prompt is NOT here: it goes over stdin (`-`), keeping
64
+ * task text out of `ps` and clear of argv limits.
65
+ */
66
+ export function buildCodexArgs(input: BuildCodexArgsInput): string[] {
67
+ const { request, config, capabilities, lastMessageFile, skipGitRepoCheck, readOnly, fork } = input;
68
+ const args: string[] = ["exec"];
69
+
70
+ if (request.sessionId && capabilities.supportsResumeSubcommand && !fork) {
71
+ args.push("resume", request.sessionId);
72
+ }
73
+
74
+ if (capabilities.supportsJson) args.push("--json");
75
+ args.push("-C", request.cwd);
76
+ if (capabilities.supportsSandboxMode) args.push("-s", readOnly ? "read-only" : "workspace-write");
77
+
78
+ if (lastMessageFile && capabilities.supportsOutputLastMessage) args.push("-o", lastMessageFile);
79
+ if (skipGitRepoCheck) args.push("--skip-git-repo-check");
80
+
81
+ const model = request.model ?? config.model;
82
+ if (model) args.push("-m", model);
83
+
84
+ for (const dir of config.additionalDirs) args.push("--add-dir", dir);
85
+
86
+ // `-` = read the prompt from stdin. Always last.
87
+ args.push("-");
88
+ return args;
89
+ }
90
+
91
+ /**
92
+ * Honest read-only enforcement (T-602): derived from the argv we actually built, never
93
+ * from what was merely requested. `-s read-only` is an OS-enforced sandbox (seatbelt on
94
+ * macOS, landlock on Linux) — real isolation, not a request the model can ignore — so it
95
+ * is safe to report `true` when, and only when, that exact flag pair is present. If
96
+ * `capabilities.supportsSandboxMode` is false the flag was never emitted and this
97
+ * correctly reports `false`: we never claim isolation we could not prove.
98
+ */
99
+ export function codexReadOnlyEnforcement(args: string[], readOnly: boolean): { enforced: boolean; mechanism?: string } {
100
+ if (!readOnly) return { enforced: false };
101
+ const idx = args.indexOf("-s");
102
+ const enforced = idx !== -1 && args[idx + 1] === "read-only";
103
+ return enforced ? { enforced: true, mechanism: "-s read-only" } : { enforced: false };
104
+ }
105
+
106
+ function isGitRepo(cwd: string): boolean {
107
+ try {
108
+ return Bun.spawnSync({ cmd: ["git", "rev-parse", "--is-inside-work-tree"], cwd, stdout: "ignore", stderr: "ignore" })
109
+ .exitCode === 0;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ export class CodexAgent implements ExternalAgent {
116
+ readonly name = "codex" as const;
117
+
118
+ constructor(private readonly config: AgentConfig) {}
119
+
120
+ async isAvailable(force = false) {
121
+ return detect("codex", this.config, { cwd: process.cwd(), force });
122
+ }
123
+
124
+ async run(request: AgentRequest, options: AgentRunOptions): Promise<AgentResult> {
125
+ const started = Date.now();
126
+ const executable = resolveExecutable(this.config.executable);
127
+ if (!executable) {
128
+ throw new AgentError({
129
+ code: "EXECUTABLE_NOT_FOUND",
130
+ agent: "codex",
131
+ message: `\`${this.config.executable}\` was not found on PATH. Install the Codex CLI, or set multiHarness.codex.executable to its full path.`,
132
+ });
133
+ }
134
+
135
+ const availability = await detect("codex", this.config, { cwd: request.cwd });
136
+ const capabilities = codexCapabilities(availability.version);
137
+
138
+ const readOnly = request.readOnly ?? (request.mode ? MODE_DEFAULT_READ_ONLY[request.mode] : false);
139
+ const timeoutMs = request.timeoutMs ?? this.config.timeoutMs;
140
+
141
+ const tempDir = mkdtempSync(join(tmpdir(), "multi-harness-"));
142
+ const lastMessageFile = join(tempDir, "last-message.txt");
143
+
144
+ const args = buildCodexArgs({
145
+ request,
146
+ config: this.config,
147
+ capabilities,
148
+ lastMessageFile,
149
+ skipGitRepoCheck: !isGitRepo(request.cwd),
150
+ readOnly,
151
+ fork: request.fork,
152
+ });
153
+
154
+ const state = newCodexStreamState();
155
+ const reader = new JsonlReader((value) => {
156
+ if (value === undefined) return;
157
+ const progress = applyCodexEvent(state, value);
158
+ if (progress) options.onProgress?.(progress);
159
+ });
160
+
161
+ try {
162
+ options.onProgress?.({ phase: "starting" });
163
+
164
+ const result = await spawnAgent({
165
+ command: executable,
166
+ args,
167
+ cwd: request.cwd,
168
+ stdin: request.context ? `${request.context}\n\n${request.task}` : request.task,
169
+ timeoutMs,
170
+ signal: options.signal,
171
+ onStdout: (chunk) => reader.push(chunk),
172
+ });
173
+ reader.end();
174
+
175
+ if (result.cancelled) throw cancelled("codex");
176
+ if (result.timedOut) throw timedOut("codex", timeoutMs);
177
+
178
+ const failure = state.failure;
179
+ if (failure) {
180
+ if (isQuotaFailure(failure)) throw providerLimit("codex", failure);
181
+ if (looksLikeAuthFailure(failure)) throw authRequired("codex", failure);
182
+ throw processFailed("codex", result.exitCode, failure);
183
+ }
184
+
185
+ // Codex logs unrelated warnings to stderr, so stderr alone never decides failure.
186
+ if (result.exitCode !== 0) {
187
+ const tail = result.stderr.trim().split("\n").slice(-3).join("\n");
188
+ if (looksLikeAuthFailure(result.stderr)) throw authRequired("codex", tail);
189
+ throw processFailed("codex", result.exitCode, tail);
190
+ }
191
+
192
+ let output = "";
193
+ try {
194
+ output = readFileSync(lastMessageFile, "utf8").trim();
195
+ } catch {
196
+ /* the file is absent when the CLI had nothing to write */
197
+ }
198
+ if (!output) output = state.lastAgentMessage?.trim() ?? "";
199
+ if (!output) {
200
+ throw invalidOutput(
201
+ "codex",
202
+ reader.stats.parsed === 0
203
+ ? `no JSON events were produced (${reader.stats.lines} output lines) — does this codex version support --json?`
204
+ : "the run finished without a final message",
205
+ );
206
+ }
207
+
208
+ const enforcement = codexReadOnlyEnforcement(args, readOnly);
209
+
210
+ return {
211
+ agent: "codex",
212
+ success: true,
213
+ output,
214
+ sessionId: state.sessionId,
215
+ exitCode: result.exitCode,
216
+ durationMs: Date.now() - started,
217
+ metadata: {
218
+ readOnlyEnforced: enforcement.enforced,
219
+ readOnlyMechanism: enforcement.mechanism,
220
+ cliVersion: availability.version,
221
+ items: state.itemCount,
222
+ parseErrors: reader.stats.parseErrors,
223
+ model: request.model ?? this.config.model ?? null,
224
+ // Codex has no `--fork-session`; a forked request omits `resume` and starts a
225
+ // fresh session instead (see buildCodexArgs / types.ts), reported honestly here.
226
+ forked: Boolean(request.fork),
227
+ },
228
+ };
229
+ } catch (e) {
230
+ if (e instanceof SpawnCwdError) throw invalidCwd("codex", request.cwd, e.message);
231
+ throw e;
232
+ } finally {
233
+ rmSync(tempDir, { recursive: true, force: true });
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,81 @@
1
+ /** Provider-neutral agent interface. See _spec/02-agent-interface.md. */
2
+ import type { AgentMode, AgentName } from "../config/schema.ts";
3
+
4
+ export type { AgentMode, AgentName };
5
+
6
+ export interface AgentRequest {
7
+ agent: AgentName;
8
+ task: string;
9
+ cwd: string;
10
+ mode?: AgentMode;
11
+ /** Prior worker session id to resume. */
12
+ sessionId?: string;
13
+ /**
14
+ * Fork the resumed session instead of continuing it, so two runs can share a parent
15
+ * without racing on one worker session. Claude honors this via `--fork-session`; Codex
16
+ * has no equivalent, so a forked Codex run starts fresh instead (_spec/08).
17
+ */
18
+ fork?: boolean;
19
+ /** Compact handoff context — never the OMP transcript. */
20
+ context?: string;
21
+ timeoutMs?: number;
22
+ /** Request provider-enforced read-only execution. */
23
+ readOnly?: boolean;
24
+ /** Per-call worker model override; falls back to config, then the CLI's own config. */
25
+ model?: string;
26
+ }
27
+
28
+ export interface AgentResult {
29
+ agent: AgentName;
30
+ success: boolean;
31
+ /** Final textual answer only — never the raw event stream. */
32
+ output: string;
33
+ sessionId?: string;
34
+ exitCode: number | null;
35
+ durationMs: number;
36
+ stderr?: string;
37
+ metadata?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface AgentProgress {
41
+ /** e.g. "starting", "inspecting repository", "running tests", "completed" */
42
+ phase: string;
43
+ detail?: string;
44
+ /** Provider-native event kind — debug logs only. */
45
+ raw?: string;
46
+ }
47
+
48
+ export interface AgentRunOptions {
49
+ signal: AbortSignal;
50
+ onProgress?: (event: AgentProgress) => void;
51
+ }
52
+
53
+ export type AuthState = "ok" | "logged-out" | "unknown";
54
+
55
+ export interface AgentAvailability {
56
+ agent: AgentName;
57
+ available: boolean;
58
+ executablePath?: string;
59
+ version?: string;
60
+ auth: AuthState;
61
+ /** One line, safe to display. Never contains credentials. */
62
+ authDetail: string;
63
+ /** Present when `available` is false. */
64
+ reason?: string;
65
+ }
66
+
67
+ export interface ExternalAgent {
68
+ readonly name: AgentName;
69
+ isAvailable(force?: boolean): Promise<AgentAvailability>;
70
+ run(request: AgentRequest, options: AgentRunOptions): Promise<AgentResult>;
71
+ }
72
+
73
+ /** Mode defaults. An explicit readOnly on the request always wins. */
74
+ export const MODE_DEFAULT_READ_ONLY: Record<AgentMode, boolean> = {
75
+ analyze: true,
76
+ plan: true,
77
+ review: true,
78
+ implement: false,
79
+ debug: false,
80
+ test: false,
81
+ };