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,178 @@
1
+ /** Typed, actionable errors. Every message names the fix. See _spec/10-errors-and-security.md. */
2
+ import type { AgentName } from "../config/schema.ts";
3
+ import { redactTail } from "./redact.ts";
4
+
5
+ export type AgentErrorCode =
6
+ | "EXECUTABLE_NOT_FOUND"
7
+ | "AUTH_REQUIRED"
8
+ | "PROCESS_FAILED"
9
+ | "TIMEOUT"
10
+ | "CANCELLED"
11
+ | "INVALID_OUTPUT"
12
+ | "SESSION_RESUME_FAILED"
13
+ | "WORKSPACE_BUSY"
14
+ | "AGENT_DISABLED"
15
+ | "INVALID_CWD"
16
+ | "PROVIDER_LIMIT";
17
+
18
+ export interface AgentErrorInit {
19
+ code: AgentErrorCode;
20
+ agent: AgentName;
21
+ message: string;
22
+ exitCode?: number | null;
23
+ stderrTail?: string;
24
+ cause?: unknown;
25
+ }
26
+
27
+ export class AgentError extends Error {
28
+ readonly code: AgentErrorCode;
29
+ readonly agent: AgentName;
30
+ readonly exitCode: number | null | undefined;
31
+ readonly stderrTail: string | undefined;
32
+
33
+ constructor(init: AgentErrorInit) {
34
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
35
+ this.name = "AgentError";
36
+ this.code = init.code;
37
+ this.agent = init.agent;
38
+ this.exitCode = init.exitCode;
39
+ this.stderrTail = init.stderrTail;
40
+ }
41
+ }
42
+
43
+ const LOGIN_COMMAND: Record<AgentName, string> = {
44
+ codex: "codex login",
45
+ claude: "claude auth login",
46
+ };
47
+
48
+ const INSTALL_HINT: Record<AgentName, string> = {
49
+ codex: "Install the Codex CLI, or set multiHarness.codex.executable to its full path.",
50
+ claude: "Install Claude Code, or set multiHarness.claude.executable to its full path.",
51
+ };
52
+
53
+ const DISPLAY: Record<AgentName, string> = { codex: "Codex", claude: "Claude Code" };
54
+
55
+ /** Stderr signatures that mean "the user needs to log in", not "the task failed". */
56
+ const AUTH_PATTERNS = [
57
+ /not logged in/i,
58
+ /please (run )?`?(codex )?login/i,
59
+ /authentication (required|failed)/i,
60
+ /unauthorized/i,
61
+ /invalid[_ ]api[_ ]key/i,
62
+ /no credentials found/i,
63
+ /session expired/i,
64
+ /oauth token (expired|invalid)/i,
65
+ ];
66
+
67
+ export function looksLikeAuthFailure(stderr: string): boolean {
68
+ return AUTH_PATTERNS.some((p) => p.test(stderr));
69
+ }
70
+
71
+ export function executableNotFound(agent: AgentName, executable: string): AgentError {
72
+ return new AgentError({
73
+ code: "EXECUTABLE_NOT_FOUND",
74
+ agent,
75
+ message: `\`${executable}\` was not found on PATH. ${INSTALL_HINT[agent]}`,
76
+ });
77
+ }
78
+
79
+ export function authRequired(agent: AgentName, stderrTail?: string): AgentError {
80
+ // Redact before it ever touches `.message` or `.stderrTail` — both are user/log visible.
81
+ const tail = stderrTail === undefined ? undefined : redactTail(stderrTail);
82
+ return new AgentError({
83
+ code: "AUTH_REQUIRED",
84
+ agent,
85
+ message:
86
+ `${DISPLAY[agent]} is installed but not authenticated. ` +
87
+ `Run \`${LOGIN_COMMAND[agent]}\` in a terminal and complete its normal login flow, then retry. ` +
88
+ `This extension never logs in on your behalf.`,
89
+ stderrTail: tail,
90
+ });
91
+ }
92
+
93
+ export function processFailed(agent: AgentName, exitCode: number | null, stderrTail?: string): AgentError {
94
+ const tail = stderrTail === undefined ? undefined : redactTail(stderrTail);
95
+ return new AgentError({
96
+ code: "PROCESS_FAILED",
97
+ agent,
98
+ message: `${DISPLAY[agent]} exited with code ${exitCode ?? "null"}.${tail ? ` Last output: ${tail}` : ""}`,
99
+ exitCode,
100
+ stderrTail: tail,
101
+ });
102
+ }
103
+
104
+ export function timedOut(agent: AgentName, timeoutMs: number): AgentError {
105
+ return new AgentError({
106
+ code: "TIMEOUT",
107
+ agent,
108
+ message:
109
+ `${DISPLAY[agent]} did not finish within ${Math.round(timeoutMs / 1000)}s and was terminated. ` +
110
+ `Raise multiHarness.${agent}.timeoutMs or narrow the task.`,
111
+ });
112
+ }
113
+
114
+ export function cancelled(agent: AgentName): AgentError {
115
+ return new AgentError({ code: "CANCELLED", agent, message: `${DISPLAY[agent]} run was cancelled.` });
116
+ }
117
+
118
+ export function invalidOutput(agent: AgentName, detail: string): AgentError {
119
+ return new AgentError({
120
+ code: "INVALID_OUTPUT",
121
+ agent,
122
+ // `detail` often quotes a raw output fragment — redact it like any other CLI-derived text.
123
+ message: `Could not read a final answer from ${DISPLAY[agent]}: ${redactTail(detail)}`,
124
+ });
125
+ }
126
+
127
+ /**
128
+ * The provider refused on quota/credits/billing. Distinct from PROCESS_FAILED because
129
+ * retrying cannot help — observed on codex-cli 0.155.0 as
130
+ * "Your workspace is out of credits."
131
+ */
132
+ export function providerLimit(agent: AgentName, detail: string): AgentError {
133
+ // `detail` is lifted straight from the CLI's failure message (spec 10) — redact it too.
134
+ return new AgentError({
135
+ code: "PROVIDER_LIMIT",
136
+ agent,
137
+ message:
138
+ `${DISPLAY[agent]} refused the request: ${redactTail(detail)} ` +
139
+ `This is a provider account limit, not a problem with the task — retrying will not help. ` +
140
+ `Top up or switch accounts in ${agent === "codex" ? "your OpenAI/ChatGPT" : "your Anthropic"} plan, then retry.`,
141
+ });
142
+ }
143
+
144
+ export function agentDisabled(agent: AgentName): AgentError {
145
+ return new AgentError({
146
+ code: "AGENT_DISABLED",
147
+ agent,
148
+ message: `${DISPLAY[agent]} is disabled in config. Set multiHarness.${agent}.enabled: true to use it.`,
149
+ });
150
+ }
151
+
152
+ export function invalidCwd(agent: AgentName, cwd: string, reason: string): AgentError {
153
+ return new AgentError({ code: "INVALID_CWD", agent, message: `Cannot run ${DISPLAY[agent]} in ${cwd}: ${reason}` });
154
+ }
155
+
156
+ export function workspaceBusy(agent: AgentName, holder: string): AgentError {
157
+ return new AgentError({
158
+ code: "WORKSPACE_BUSY",
159
+ agent,
160
+ message:
161
+ `Another write-capable agent (${holder}) is working in this repository. ` +
162
+ `Wait for it, run this read-only, or cancel it with /sessions.`,
163
+ });
164
+ }
165
+
166
+ /** Map a spawn-level failure onto a typed error. */
167
+ export function fromSpawnError(agent: AgentName, executable: string, err: NodeJS.ErrnoException): AgentError {
168
+ if (err.code === "ENOENT") return executableNotFound(agent, executable);
169
+ if (err.code === "EACCES") {
170
+ return new AgentError({
171
+ code: "EXECUTABLE_NOT_FOUND",
172
+ agent,
173
+ message: `\`${executable}\` is not executable (EACCES). Check its permissions.`,
174
+ cause: err,
175
+ });
176
+ }
177
+ return new AgentError({ code: "PROCESS_FAILED", agent, message: `Failed to start ${executable}: ${redactTail(err.message)}`, cause: err });
178
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Redaction for anything that might reach a log line, an error message, a status line, or a
3
+ * persisted session file. See _spec/10-errors-and-security.md ("Logging", "Security
4
+ * requirements (hard)").
5
+ *
6
+ * Every pattern here is a false-negative-averse guess, not a credential validator — we would
7
+ * rather redact a git SHA that merely looks key-shaped than let a real key through. Patterns
8
+ * are ordered so that wider matches (PEM blocks, header lines) run before narrower token
9
+ * matches, and the placeholder text is chosen so it never re-matches any pattern below —
10
+ * that's what makes `redact` idempotent.
11
+ */
12
+
13
+ const PLACEHOLDER = "[REDACTED]";
14
+
15
+ /**
16
+ * Applied in order. Each is a standalone RegExp (not a matchAll accumulator) so one giant
17
+ * alternation doesn't become an unreadable, unmaintainable regex — and so PEM blocks (which
18
+ * span lines) can run before line-oriented patterns without them fighting over the same text.
19
+ * `replacement` is passed straight to String#replace, so `$1` etc. refer to that pattern's own
20
+ * capture groups.
21
+ */
22
+ const PATTERNS: Array<{ re: RegExp; replacement: string }> = [
23
+ // PEM private key blocks — widest match first, multiline.
24
+ { re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, replacement: PLACEHOLDER },
25
+ // `Authorization: <anything>` header lines — redact the whole value, not just a token shape.
26
+ { re: /authorization\s*:\s*\S+(?:\s+\S+)?/gi, replacement: PLACEHOLDER },
27
+ // Explicit bearer tokens outside a header line (e.g. logged as `token=Bearer xyz`).
28
+ { re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, replacement: PLACEHOLDER },
29
+ // OpenAI/Anthropic-style secret keys: sk-..., sk-ant-..., sk-proj-..., etc.
30
+ { re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{10,}/g, replacement: PLACEHOLDER },
31
+ // GitHub tokens: ghp_/gho_/ghu_/ghs_ (36 chars) and the newer github_pat_ format.
32
+ { re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replacement: PLACEHOLDER },
33
+ { re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: PLACEHOLDER },
34
+ // AWS access key id.
35
+ { re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: PLACEHOLDER },
36
+ // Google API key.
37
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: PLACEHOLDER },
38
+ // JWT: three base64url segments joined by dots. Segment length floor keeps this from
39
+ // matching short dotted things like semver (1.20.5) — see redaction.test.ts for the
40
+ // false-positive trade-offs we accept.
41
+ { re: /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replacement: PLACEHOLDER },
42
+ // Shell-style assignments: KEY=..., API_TOKEN=..., DB_SECRET=..., MY_PASSWORD=...
43
+ // Value is everything up to whitespace/quote-close — redact the whole RHS, never just a
44
+ // prefix, since we don't know the value's shape ahead of time. Keep the key name ($1):
45
+ // which var leaked matters for debugging, the value never does.
46
+ { re: /\b([A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/gi, replacement: `$1=${PLACEHOLDER}` },
47
+ ];
48
+
49
+ /**
50
+ * Replace anything that looks like a credential with a stable placeholder. Never throws:
51
+ * empty strings, huge strings, and lone surrogates all fall through untouched or redacted,
52
+ * never raise.
53
+ *
54
+ * Idempotent by construction — `PLACEHOLDER` contains no `-`, `_`, `=`, or `.` runs long
55
+ * enough to satisfy any pattern above, so redacting its own output is a no-op.
56
+ */
57
+ export function redact(text: string): string {
58
+ if (!text) return text;
59
+ let out = text;
60
+ for (const { re, replacement } of PATTERNS) out = out.replace(re, replacement);
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Redact and cap length, for stderr/stdout tails — the main leak path in this codebase (a
66
+ * CLI's own error output routinely echoes back flags, env, or partial request bodies). Cuts
67
+ * from the front and keeps the tail, matching how `RingBuffer` in spawn-agent.ts already
68
+ * thinks about truncation, so a snippet always shows the most recent, most relevant lines.
69
+ */
70
+ export function redactTail(text: string, maxChars = 500): string {
71
+ if (!text) return text;
72
+ const redacted = redact(text);
73
+ return redacted.length > maxChars ? `…${redacted.slice(redacted.length - maxChars)}` : redacted;
74
+ }
75
+
76
+ /**
77
+ * Allowlist, not denylist: a denylist over env var *names* fails open — any secret handed to
78
+ * the child under an unanticipated name (CUSTOM_API_SECRET, VENDOR_X_CRED, a typo'd variant)
79
+ * would sail straight through. An allowlist of known-harmless names can only fail closed:
80
+ * worst case we under-log, never leak. Keep this list short and boring.
81
+ */
82
+ const ENV_ALLOWLIST = new Set([
83
+ "PATH",
84
+ "HOME",
85
+ "LANG",
86
+ "LC_ALL",
87
+ "LC_CTYPE",
88
+ "TERM",
89
+ "SHELL",
90
+ "PWD",
91
+ "OLDPWD",
92
+ "TMPDIR",
93
+ "TZ",
94
+ "NODE_ENV",
95
+ "CI",
96
+ "USER",
97
+ "LOGNAME",
98
+ "EDITOR",
99
+ "VISUAL",
100
+ "COLORTERM",
101
+ ]);
102
+
103
+ export interface RedactedEnv {
104
+ /** Allowlisted vars that were present, verbatim. */
105
+ kept: Record<string, string>;
106
+ /** How many vars existed but were not on the allowlist — count only, never their names or values. */
107
+ omitted: number;
108
+ }
109
+
110
+ /** Safe-to-log view of an environment object. See ENV_ALLOWLIST for the "why allowlist" note. */
111
+ export function redactEnv(env: NodeJS.ProcessEnv | Record<string, string | undefined>): RedactedEnv {
112
+ const kept: Record<string, string> = {};
113
+ let omitted = 0;
114
+ for (const [name, value] of Object.entries(env)) {
115
+ if (value === undefined) continue;
116
+ if (ENV_ALLOWLIST.has(name)) kept[name] = value;
117
+ else omitted++;
118
+ }
119
+ return { kept, omitted };
120
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The only place this project spawns a process.
3
+ *
4
+ * Contract (_spec/05-process-runner.md): argument arrays only, never a shell; prompts on
5
+ * stdin; independent capped output buffers; timeout and AbortSignal both terminate the
6
+ * whole process group; exactly one resolution; every listener cleaned up.
7
+ */
8
+ import { type ChildProcess, spawn } from "node:child_process";
9
+ import { statSync } from "node:fs";
10
+ import { realpathSync } from "node:fs";
11
+
12
+ const IS_WINDOWS = process.platform === "win32";
13
+
14
+ export interface SpawnAgentOptions {
15
+ command: string;
16
+ args: string[];
17
+ cwd: string;
18
+ env?: NodeJS.ProcessEnv;
19
+ /** Written to the child's stdin, which is then closed. Keeps prompts out of `ps`. */
20
+ stdin?: string;
21
+ timeoutMs?: number;
22
+ signal?: AbortSignal;
23
+ /** Milliseconds between SIGTERM and SIGKILL. */
24
+ killGraceMs?: number;
25
+ maxBufferBytes?: number;
26
+ onStdout?: (chunk: string) => void;
27
+ onStderr?: (chunk: string) => void;
28
+ }
29
+
30
+ export interface SpawnAgentResult {
31
+ exitCode: number | null;
32
+ signal: NodeJS.Signals | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ durationMs: number;
36
+ timedOut: boolean;
37
+ cancelled: boolean;
38
+ }
39
+
40
+ /** Byte-capped buffer that keeps the tail — a runaway agent must not exhaust memory. */
41
+ export class RingBuffer {
42
+ #chunks: string[] = [];
43
+ #bytes = 0;
44
+ constructor(private readonly maxBytes: number) {}
45
+
46
+ push(chunk: string): void {
47
+ this.#chunks.push(chunk);
48
+ this.#bytes += Buffer.byteLength(chunk);
49
+ while (this.#bytes > this.maxBytes && this.#chunks.length > 1) {
50
+ const dropped = this.#chunks.shift()!;
51
+ this.#bytes -= Buffer.byteLength(dropped);
52
+ }
53
+ }
54
+
55
+ get text(): string {
56
+ return this.#chunks.join("");
57
+ }
58
+ }
59
+
60
+ export class SpawnCwdError extends Error {}
61
+
62
+ /** Terminate the child and everything it started. */
63
+ function terminate(child: ChildProcess, signal: NodeJS.Signals): void {
64
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
65
+ try {
66
+ if (IS_WINDOWS) {
67
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: false }).unref();
68
+ } else {
69
+ // Negative pid = the whole process group, so the CLI's own children die too.
70
+ process.kill(-child.pid, signal);
71
+ }
72
+ } catch {
73
+ try {
74
+ child.kill(signal);
75
+ } catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function spawnAgent(options: SpawnAgentOptions): Promise<SpawnAgentResult> {
82
+ const {
83
+ command,
84
+ args,
85
+ cwd,
86
+ env = process.env,
87
+ stdin,
88
+ timeoutMs,
89
+ signal,
90
+ killGraceMs = 5_000,
91
+ maxBufferBytes = 1_048_576,
92
+ onStdout,
93
+ onStderr,
94
+ } = options;
95
+
96
+ let realCwd: string;
97
+ try {
98
+ realCwd = realpathSync(cwd);
99
+ if (!statSync(realCwd).isDirectory()) throw new Error("not a directory");
100
+ } catch (e) {
101
+ throw new SpawnCwdError(`${cwd}: ${(e as Error).message}`);
102
+ }
103
+
104
+ if (signal?.aborted) {
105
+ return { exitCode: null, signal: null, stdout: "", stderr: "", durationMs: 0, timedOut: false, cancelled: true };
106
+ }
107
+
108
+ const started = Date.now();
109
+ const stdout = new RingBuffer(maxBufferBytes);
110
+ const stderr = new RingBuffer(maxBufferBytes);
111
+
112
+ return await new Promise<SpawnAgentResult>((resolvePromise, rejectPromise) => {
113
+ let child: ChildProcess;
114
+ try {
115
+ child = spawn(command, args, {
116
+ cwd: realCwd,
117
+ env,
118
+ shell: false,
119
+ // Own process group so we can signal the whole tree on cancel/timeout.
120
+ detached: !IS_WINDOWS,
121
+ stdio: ["pipe", "pipe", "pipe"],
122
+ });
123
+ } catch (e) {
124
+ rejectPromise(e);
125
+ return;
126
+ }
127
+
128
+ let settled = false;
129
+ let timedOut = false;
130
+ let cancelled = false;
131
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
132
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
133
+
134
+ const escalate = () => {
135
+ graceTimer = setTimeout(() => terminate(child, "SIGKILL"), killGraceMs);
136
+ graceTimer.unref?.();
137
+ };
138
+
139
+ const onAbort = () => {
140
+ cancelled = true;
141
+ terminate(child, "SIGTERM");
142
+ escalate();
143
+ };
144
+
145
+ const cleanup = () => {
146
+ if (timeoutTimer) clearTimeout(timeoutTimer);
147
+ if (graceTimer) clearTimeout(graceTimer);
148
+ signal?.removeEventListener("abort", onAbort);
149
+ child.stdout?.removeAllListeners();
150
+ child.stderr?.removeAllListeners();
151
+ child.removeAllListeners();
152
+ };
153
+
154
+ const settle = (result: SpawnAgentResult) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ cleanup();
158
+ resolvePromise(result);
159
+ };
160
+
161
+ const fail = (err: unknown) => {
162
+ if (settled) return;
163
+ settled = true;
164
+ cleanup();
165
+ rejectPromise(err);
166
+ };
167
+
168
+ if (timeoutMs && timeoutMs > 0) {
169
+ timeoutTimer = setTimeout(() => {
170
+ timedOut = true;
171
+ terminate(child, "SIGTERM");
172
+ escalate();
173
+ }, timeoutMs);
174
+ timeoutTimer.unref?.();
175
+ }
176
+
177
+ signal?.addEventListener("abort", onAbort, { once: true });
178
+
179
+ // Persistent decoders: a multi-byte character split across chunks must not corrupt.
180
+ const outDecoder = new TextDecoder("utf8");
181
+ const errDecoder = new TextDecoder("utf8");
182
+
183
+ child.stdout?.on("data", (buf: Buffer) => {
184
+ const text = outDecoder.decode(buf, { stream: true });
185
+ if (!text) return;
186
+ stdout.push(text);
187
+ onStdout?.(text);
188
+ });
189
+ child.stderr?.on("data", (buf: Buffer) => {
190
+ const text = errDecoder.decode(buf, { stream: true });
191
+ if (!text) return;
192
+ stderr.push(text);
193
+ onStderr?.(text);
194
+ });
195
+
196
+ child.on("error", fail);
197
+
198
+ child.on("close", (code, sig) => {
199
+ settle({
200
+ exitCode: code,
201
+ signal: sig,
202
+ stdout: stdout.text,
203
+ stderr: stderr.text,
204
+ durationMs: Date.now() - started,
205
+ timedOut,
206
+ cancelled,
207
+ });
208
+ });
209
+
210
+ if (child.stdin) {
211
+ child.stdin.on("error", () => {
212
+ /* child may exit before we finish writing — not fatal */
213
+ });
214
+ if (stdin !== undefined) child.stdin.write(stdin);
215
+ child.stdin.end();
216
+ }
217
+ });
218
+ }
@@ -0,0 +1,59 @@
1
+ /** Compact context handoff and output shaping. See _spec/02-agent-interface.md. */
2
+ import type { AgentMode, AgentResult } from "../agents/types.ts";
3
+
4
+ /** Truncate from the middle so both the opening and the conclusion survive. */
5
+ export function truncateMiddle(text: string, maxChars: number): { text: string; truncated: boolean } {
6
+ if (text.length <= maxChars) return { text, truncated: false };
7
+ const keep = Math.max(200, Math.floor((maxChars - 80) / 2));
8
+ const head = text.slice(0, keep);
9
+ const tail = text.slice(-keep);
10
+ const dropped = text.length - head.length - tail.length;
11
+ return { text: `${head}\n\n[… ${dropped.toLocaleString()} characters omitted …]\n\n${tail}`, truncated: true };
12
+ }
13
+
14
+ const MODE_PREAMBLE: Record<AgentMode, string> = {
15
+ analyze: "Analyze and explain. Do not modify any files.",
16
+ plan: "Produce a concrete plan. Do not modify any files.",
17
+ review: "Review the code and report findings, most important first. Do not modify any files.",
18
+ implement: "Implement the change. Keep the diff minimal and focused.",
19
+ debug: "Find the root cause first, then fix it.",
20
+ test: "Run the relevant tests and repair what fails.",
21
+ };
22
+
23
+ export interface HandoffInput {
24
+ task: string;
25
+ mode?: AgentMode;
26
+ context?: string;
27
+ maxChars: number;
28
+ }
29
+
30
+ /**
31
+ * Build the text sent to a worker: a short mode preamble, optional caller context, then the
32
+ * task. Never the OMP transcript — the worker can read the repository itself.
33
+ */
34
+ export function buildHandoff(input: HandoffInput): string {
35
+ const sections: string[] = [];
36
+ if (input.mode) sections.push(MODE_PREAMBLE[input.mode]);
37
+ if (input.context?.trim()) {
38
+ const { text } = truncateMiddle(input.context.trim(), input.maxChars);
39
+ sections.push(`Context from the supervisor:\n${text}`);
40
+ }
41
+ sections.push(`Task:\n${input.task.trim()}`);
42
+ return sections.join("\n\n");
43
+ }
44
+
45
+ /** One-line summary used in run lists and status lines. */
46
+ export function summarize(task: string, max = 60): string {
47
+ const flat = task.replace(/\s+/g, " ").trim();
48
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
49
+ }
50
+
51
+ /** Compact, human-readable rendering of a finished run. */
52
+ export function renderResult(result: AgentResult, maxOutputChars: number): { text: string; truncated: boolean } {
53
+ const { text, truncated } = truncateMiddle(result.output, maxOutputChars);
54
+ const seconds = (result.durationMs / 1000).toFixed(1);
55
+ const bits = [result.agent, `${seconds}s`];
56
+ if (result.sessionId) bits.push(`session ${result.sessionId.slice(0, 8)}`);
57
+ if (result.metadata?.readOnlyEnforced === true) bits.push("read-only");
58
+ return { text: `[${bits.join(" · ")}]\n${text}`, truncated };
59
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Routing guidance for the OMP supervisor (T-503).
3
+ *
4
+ * OMP's `ToolDefinition` has **no** `promptSnippet` / `promptGuidelines` fields — those are
5
+ * upstream `pi` only (verified against 18.2.6). Guidance therefore rides in two supported
6
+ * places: each tool's `description`, and the `before_agent_start` event, whose result may
7
+ * return a replacement `systemPrompt: string[]` ("Extensions chain in order"). That event is
8
+ * the only system-prompt contribution surface `ExtensionAPI` offers; nothing here invents a
9
+ * field that does not exist.
10
+ */
11
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
12
+ import type { MultiHarnessConfig } from "../config/schema.ts";
13
+
14
+ /** One line per agent — also the capability summary handed to the router model. */
15
+ export const AGENT_SCOPES = {
16
+ codex:
17
+ "Codex — implementation, debugging, refactoring, tests, repository modification, targeted code review.",
18
+ claude:
19
+ "Claude — architecture analysis, planning, design review, broad repository reasoning, second opinions, conceptual risk.",
20
+ } as const;
21
+
22
+ /** What each tool is for, in the supervisor's own words. */
23
+ export const TOOL_SCOPES = {
24
+ ask_codex: "Hand one self-contained coding task to the Codex CLI and get its answer.",
25
+ ask_claude: "Hand one self-contained reasoning or review task to the Claude Code CLI and get its answer.",
26
+ delegate: "Same surface, but `agent: \"auto\"` lets the harness pick; the result always reports how it routed.",
27
+ agent_runs: "Manage background runs: list, status, result, cancel, wait. This is how you join fan-out work.",
28
+ } as const;
29
+
30
+ /**
31
+ * The system-prompt contribution. Preferences, not a workflow: the supervisor must stay
32
+ * free to answer directly or to call one agent only (_spec/12 E).
33
+ */
34
+ export const ROUTING_GUIDANCE = [
35
+ "# Delegating to external coding agents (multi-harness)",
36
+ "",
37
+ `${AGENT_SCOPES.codex}`,
38
+ `${AGENT_SCOPES.claude}`,
39
+ "",
40
+ `- ask_codex — ${TOOL_SCOPES.ask_codex}`,
41
+ `- ask_claude — ${TOOL_SCOPES.ask_claude}`,
42
+ `- delegate — ${TOOL_SCOPES.delegate}`,
43
+ `- agent_runs — ${TOOL_SCOPES.agent_runs}`,
44
+ "",
45
+ "These are preferences, not a required workflow:",
46
+ "- Do not delegate trivial work, and do not invoke both agents when one suffices.",
47
+ "- For hard tasks, consider Claude plans → Codex implements → Claude reviews read-only → Codex fixes.",
48
+ "- Prefer a read-only review before giving another agent write access to the same files.",
49
+ "- Use background runs only when two tasks are genuinely independent; join them with agent_runs.",
50
+ "- State each task as a self-contained instruction: a worker cannot see this conversation.",
51
+ ].join("\n");
52
+
53
+ export interface RoutingGuidanceDeps {
54
+ pi: ExtensionAPI;
55
+ getConfig: () => MultiHarnessConfig;
56
+ }
57
+
58
+ /**
59
+ * Append {@link ROUTING_GUIDANCE} to the system prompt, gated on
60
+ * `multiHarness.routing.promptGuidance`. Registered during load; the handler re-reads config
61
+ * per turn so toggling it takes effect without a restart.
62
+ */
63
+ export function registerRoutingGuidance({ pi, getConfig }: RoutingGuidanceDeps): void {
64
+ pi.on("before_agent_start", (event) => {
65
+ const config = getConfig();
66
+ if (!config.enabled || !config.routing.promptGuidance) return;
67
+ // `event.systemPrompt` is the freshly computed base each turn, so appending cannot
68
+ // accumulate — the guard is for hosts that replay a prepared prompt.
69
+ if (event.systemPrompt.includes(ROUTING_GUIDANCE)) return;
70
+ return { systemPrompt: [...event.systemPrompt, ROUTING_GUIDANCE] };
71
+ });
72
+ }