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,286 @@
1
+ /**
2
+ * Which agent gets a task (T-501, T-504, T-506) and which worker model it runs (T-507).
3
+ *
4
+ * This module is deliberately pure and injectable: config + availability (+ an optional
5
+ * model-calling function) in, a decision out. The router model is a *dependency*, never an
6
+ * import, so the rules path — and every test — stays offline. See _spec/09-config.md
7
+ * §"Router model" and D-013.
8
+ */
9
+ import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
10
+ import type { AgentAvailability } from "../agents/types.ts";
11
+ import { isValidModelToken, type AgentMode, type AgentName, type MultiHarnessConfig } from "../config/schema.ts";
12
+
13
+ /** How the agent was picked. Always reported so routing is never a black box. */
14
+ export type RoutedBy = "explicit" | "mode" | "rules" | "model" | "fallback";
15
+
16
+ export interface RouteDecision {
17
+ ok: true;
18
+ agent: AgentName;
19
+ routedBy: RoutedBy;
20
+ /** One line, safe to show the user and to store in tool-result details. */
21
+ reason: string;
22
+ }
23
+
24
+ /** Typed refusal — no agent can run. Returned, never thrown (_spec/10). */
25
+ export interface RouteRefusal {
26
+ ok: false;
27
+ reason: string;
28
+ }
29
+
30
+ export type RouteResult = RouteDecision | RouteRefusal;
31
+
32
+ /** Whether one agent can actually be handed work right now, and why not. */
33
+ export interface AgentUsability {
34
+ usable: boolean;
35
+ reason?: string;
36
+ }
37
+
38
+ export type UsabilityMap = Record<AgentName, AgentUsability>;
39
+
40
+ /** Every agent usable — the default when nothing has been detected yet. */
41
+ export const ALL_USABLE: UsabilityMap = { codex: { usable: true }, claude: { usable: true } };
42
+
43
+ export interface RouteInput {
44
+ /** `undefined` or `"auto"` means "you decide". */
45
+ agent?: "auto" | AgentName;
46
+ task: string;
47
+ mode?: AgentMode;
48
+ }
49
+
50
+ /**
51
+ * One classification call. Returns the model's raw answer; anything it cannot do — an
52
+ * unresolvable model, an unauthenticated host, a network error — must reject, so the
53
+ * caller can fall back to rules.
54
+ */
55
+ export type RouterModelFn = (input: { prompt: string; timeoutMs: number; signal?: AbortSignal }) => Promise<string>;
56
+
57
+ export interface RouteDeps {
58
+ config: MultiHarnessConfig;
59
+ /** Defaults to "both usable" so callers that have not probed still get a decision. */
60
+ usability?: UsabilityMap;
61
+ /** Omit to skip the model path entirely (equivalent to `routing.mode: "rules"`). */
62
+ askModel?: RouterModelFn;
63
+ signal?: AbortSignal;
64
+ }
65
+
66
+ /** Combine config switches with detection results into a usability map. */
67
+ export function usabilityFrom(
68
+ config: MultiHarnessConfig,
69
+ detected?: Partial<Record<AgentName, AgentAvailability>>,
70
+ ): UsabilityMap {
71
+ const one = (agent: AgentName): AgentUsability => {
72
+ if (!config.enabled) return { usable: false, reason: "multi-harness is disabled (multiHarness.enabled: false)" };
73
+ if (!config[agent].enabled) return { usable: false, reason: `disabled in config (multiHarness.${agent}.enabled)` };
74
+ const d = detected?.[agent];
75
+ if (d && !d.available) return { usable: false, reason: d.reason ?? `${agent} is unavailable` };
76
+ return { usable: true };
77
+ };
78
+ return { codex: one("codex"), claude: one("claude") };
79
+ }
80
+
81
+ const OTHER: Record<AgentName, AgentName> = { codex: "claude", claude: "codex" };
82
+
83
+ /**
84
+ * Keyword/shape heuristics. Scores, rather than first-match, so a task mentioning both
85
+ * "refactor" and "architecture" lands on whichever it leans toward. Preferences, not rules
86
+ * — the supervisor may always call `ask_codex` / `ask_claude` directly (_spec/12 E).
87
+ *
88
+ * Phrase-level signals beat bare words where a bare word is ambiguous: "test coverage",
89
+ * "test strategy", and "test plan" describe *assessing* tests, not writing or fixing them,
90
+ * so they are Claude signals in their own right, and the bare `test(s)` Codex signal below
91
+ * excludes them via lookahead rather than colliding on the word "test". A bare "test"/"tests"
92
+ * elsewhere ("fix the failing tests", "write tests for") still reads as implementation work.
93
+ */
94
+ const CLAUDE_SIGNALS =
95
+ /\b(architect\w*|design|designs|plan|plans|planning|review|reviews|analy\w+|assess\w*|evaluat\w+|explain\w*|compare|comparison|trade-?offs?|strateg\w+|approach|rationale|risks?|scal\w+|maintainab\w+|second opinion|should we|worth it|pros and cons|test coverage|test(?:ing)? strategy|test plan)\b/i;
96
+ const CODEX_SIGNALS =
97
+ /\b(implement\w*|fix\w*|refactor\w*|rewrite|patch\w*|bug|bugs|debug\w*|tests?(?!\s+(?:coverage|strateg\w*|plan))|failing|repro\w*|stack trace|compile\w*|typecheck|lint\w*|migrat\w+|rename|endpoint|add|remove|delete|wire up|hook up|build)\b/i;
98
+
99
+ function score(text: string, pattern: RegExp): number {
100
+ return (text.match(new RegExp(pattern.source, "gi")) ?? []).length;
101
+ }
102
+
103
+ /**
104
+ * A sentence-initial imperative verb ("Analyze…", "Review…", "Implement…", "Fix…") states
105
+ * intent more strongly than the same word appearing incidentally later in the sentence, so
106
+ * it earns an extra point. Reuses the body patterns rather than a second word list, so the
107
+ * bonus can never drift out of sync with what `score` already counts.
108
+ */
109
+ function leadingVerbBonus(text: string, pattern: RegExp): number {
110
+ const leading = text.trim().match(/^[A-Za-z']+/);
111
+ if (!leading) return 0;
112
+ return new RegExp(`^(?:${pattern.source})$`, "i").test(leading[0]) ? 1 : 0;
113
+ }
114
+
115
+ /** The rule table: explicit agent → mode map → keywords → `routing.default`. No model call. */
116
+ export function routeByRules(input: RouteInput, config: MultiHarnessConfig): RouteDecision {
117
+ if (input.agent && input.agent !== "auto") {
118
+ return { ok: true, agent: input.agent, routedBy: "explicit", reason: `caller asked for ${input.agent}` };
119
+ }
120
+
121
+ if (input.mode) {
122
+ const agent = config.routing.modeMap[input.mode];
123
+ return { ok: true, agent, routedBy: "mode", reason: `mode "${input.mode}" maps to ${agent}` };
124
+ }
125
+
126
+ const text = input.task.slice(0, 2_000);
127
+ const claude = score(text, CLAUDE_SIGNALS) + leadingVerbBonus(text, CLAUDE_SIGNALS) + (/\?\s*$/.test(text.trim()) ? 1 : 0);
128
+ const codex = score(text, CODEX_SIGNALS) + leadingVerbBonus(text, CODEX_SIGNALS);
129
+ if (claude > codex) {
130
+ return { ok: true, agent: "claude", routedBy: "rules", reason: "task reads as analysis, planning, or review" };
131
+ }
132
+ if (codex > claude) {
133
+ return { ok: true, agent: "codex", routedBy: "rules", reason: "task reads as implementation, debugging, or tests" };
134
+ }
135
+
136
+ const fallback = config.routing.default;
137
+ if (fallback === "codex" || fallback === "claude") {
138
+ return { ok: true, agent: fallback, routedBy: "rules", reason: `no clear signal; routing.default is ${fallback}` };
139
+ }
140
+ // `routing.default: "auto"` with no signal at all: pick the implementer, because an
141
+ // ambiguous ask inside a repository is more often work than commentary.
142
+ return { ok: true, agent: "codex", routedBy: "rules", reason: "no clear signal; defaulting to codex" };
143
+ }
144
+
145
+ const ROUTER_SYSTEM =
146
+ "You route one task to one coding agent. " +
147
+ "codex = implementation, debugging, refactoring, tests, repository modification, targeted code review. " +
148
+ "claude = architecture analysis, planning, design review, broad repository reasoning, second opinions. " +
149
+ "Answer with exactly one word: codex or claude. No punctuation, no explanation.";
150
+
151
+ /** Router prompt. Task text is capped at 1 000 chars — this is a one-word decision. */
152
+ export function buildRouterPrompt(input: RouteInput): string {
153
+ const parts = [ROUTER_SYSTEM, ""];
154
+ if (input.mode) parts.push(`Mode: ${input.mode}`);
155
+ parts.push(`Task:\n${input.task.slice(0, 1_000)}`, "", "Answer (codex or claude):");
156
+ return parts.join("\n");
157
+ }
158
+
159
+ /** Strict single-token parse. Anything else counts as a router failure (_spec/09). */
160
+ export function parseRouterAnswer(raw: string): AgentName | undefined {
161
+ const word = raw.trim().toLowerCase().replace(/[^a-z]/g, "");
162
+ return word === "codex" || word === "claude" ? word : undefined;
163
+ }
164
+
165
+ /** Reject after `timeoutMs`; the router gets exactly one attempt, with no retry. */
166
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
167
+ return new Promise<T>((resolve, reject) => {
168
+ const timer = setTimeout(() => reject(new Error(`router model timed out after ${timeoutMs}ms`)), timeoutMs);
169
+ promise.then(
170
+ (v) => {
171
+ clearTimeout(timer);
172
+ resolve(v);
173
+ },
174
+ (e) => {
175
+ clearTimeout(timer);
176
+ reject(e);
177
+ },
178
+ );
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Decide which agent runs a task.
184
+ *
185
+ * Every failure mode of the model path degrades silently to `routeByRules`, and an
186
+ * unusable winner falls back to the other agent (T-504). The only non-decision is
187
+ * "neither agent can run", which comes back as a typed refusal.
188
+ */
189
+ export async function route(input: RouteInput, deps: RouteDeps): Promise<RouteResult> {
190
+ const { config } = deps;
191
+ const usability = deps.usability ?? ALL_USABLE;
192
+
193
+ let decision = routeByRules(input, config);
194
+
195
+ const wantsModel = (!input.agent || input.agent === "auto") && config.routing.mode === "model" && deps.askModel;
196
+ if (wantsModel && deps.askModel) {
197
+ try {
198
+ const raw = await withTimeout(
199
+ deps.askModel({ prompt: buildRouterPrompt(input), timeoutMs: config.routing.modelTimeoutMs, signal: deps.signal }),
200
+ config.routing.modelTimeoutMs,
201
+ );
202
+ const agent = parseRouterAnswer(raw);
203
+ if (agent) decision = { ok: true, agent, routedBy: "model", reason: `router model chose ${agent}` };
204
+ } catch {
205
+ // Unresolvable model, timeout, unauthenticated host, junk answer — all the same:
206
+ // keep the rules decision and say nothing. Routing must never break delegation.
207
+ }
208
+ }
209
+
210
+ const chosen = usability[decision.agent];
211
+ if (chosen.usable) return decision;
212
+
213
+ const other = OTHER[decision.agent];
214
+ if (usability[other].usable) {
215
+ return {
216
+ ok: true,
217
+ agent: other,
218
+ routedBy: "fallback",
219
+ reason: `${decision.agent} unavailable (${chosen.reason ?? "unknown reason"}); using ${other} instead`,
220
+ };
221
+ }
222
+
223
+ return {
224
+ ok: false,
225
+ reason:
226
+ `No agent is available. codex: ${usability.codex.reason ?? "unavailable"}. ` +
227
+ `claude: ${usability.claude.reason ?? "unavailable"}. Run /agents for details.`,
228
+ };
229
+ }
230
+
231
+ export type ModelSource = "call" | "config" | "cli";
232
+
233
+ export interface WorkerModelChoice {
234
+ /** `undefined` means: pass no `-m`/`--model` flag at all. */
235
+ model?: string;
236
+ source: ModelSource;
237
+ /** Present when a supplied value was rejected. */
238
+ warning?: string;
239
+ }
240
+
241
+ /**
242
+ * Worker-model precedence (T-507): per-call `model` → `multiHarness.<agent>.model` → the
243
+ * CLI's own configuration, which is the default this project promises. A value that is not
244
+ * a plain model token is dropped before it can reach argv (_spec/09 §Model selection).
245
+ */
246
+ export function resolveWorkerModel(perCall: string | undefined, configured: string | null | undefined): WorkerModelChoice {
247
+ let warning: string | undefined;
248
+
249
+ if (perCall !== undefined) {
250
+ if (isValidModelToken(perCall)) return { model: perCall, source: "call" };
251
+ warning = `ignoring model "${perCall.slice(0, 40)}": not a valid model token`;
252
+ }
253
+
254
+ if (configured !== undefined && configured !== null) {
255
+ if (isValidModelToken(configured)) return { model: configured, source: "config", warning };
256
+ warning = warning ?? `ignoring configured model "${configured.slice(0, 40)}": not a valid model token`;
257
+ }
258
+
259
+ return { source: "cli", warning };
260
+ }
261
+
262
+ /**
263
+ * The real router-model call, for wiring. One `completeSimple` through OMP's own model
264
+ * facade — `routing.model` first, then `routing.modelFallbacks` — and never a second agent
265
+ * turn. `pi-ai` is imported lazily so a host without it degrades to the rules path like any
266
+ * other failure.
267
+ */
268
+ export function createModelRouter(ctx: ExtensionContext, specs: string[]): RouterModelFn {
269
+ return async ({ prompt, signal }) => {
270
+ const model = specs.map((spec) => ctx.models.resolve(spec)).find((m) => m !== undefined);
271
+ if (!model) throw new Error(`no router model resolved (tried ${specs.join(", ")})`);
272
+
273
+ const { completeSimple } = await import("@oh-my-pi/pi-ai");
274
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
275
+ const message = await completeSimple(
276
+ model,
277
+ { messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
278
+ { apiKey: auth.ok ? auth.apiKey : undefined, maxTokens: 8, temperature: 0, signal },
279
+ );
280
+ return message.content
281
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
282
+ .map((c) => c.text)
283
+ .join("")
284
+ .trim();
285
+ };
286
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Workspace write lock (T-601, _spec/08 "Concurrency policy").
3
+ *
4
+ * Read-only runs are unrestricted. Write-capable runs take an exclusive, per-workspace
5
+ * lock so two agents never edit the same tree at once. The queue is strictly FIFO: the
6
+ * writer that asked first goes first, otherwise a long queue could starve someone.
7
+ *
8
+ * **Release contract:** `acquire` resolves with a release function that the caller MUST
9
+ * invoke from a `finally` block. Release is idempotent and safe to call after the run was
10
+ * cancelled or threw — a leaked lock wedges the workspace for the rest of the session.
11
+ *
12
+ * The key is the **realpath-normalized** cwd, so `/tmp/x` and `/private/tmp/x` (macOS) or
13
+ * a symlinked checkout are recognized as the same workspace.
14
+ */
15
+ import { realpathSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import type { AgentName } from "../agents/types.ts";
18
+ import { cancelled, workspaceBusy } from "../process/process-error.ts";
19
+
20
+ /** Call in a `finally`. Idempotent — the second and later calls do nothing. */
21
+ export type ReleaseWriteLock = () => void;
22
+
23
+ export interface AcquireWriteLockOptions {
24
+ /** Agent asking for the lock — only used to shape the `WORKSPACE_BUSY` error. */
25
+ agent: AgentName;
26
+ /** Wait for the holder (`concurrency.writerQueue: true`) or fail fast with WORKSPACE_BUSY. */
27
+ queue: boolean;
28
+ /** Short label for the lock's owner, shown to the user when someone else is busy. */
29
+ holder?: string;
30
+ /** Aborting while queued removes the waiter and rejects with CANCELLED. */
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ export interface WriteLockTable {
35
+ /** Resolves once the workspace is exclusively ours. Rejects with an `AgentError`. */
36
+ acquire(cwd: string, options: AcquireWriteLockOptions): Promise<ReleaseWriteLock>;
37
+ /** True while a writer holds this workspace — `/agents` prints "write lock: free" otherwise. */
38
+ isHeld(cwd: string): boolean;
39
+ /** Label of the current holder, if any. */
40
+ holder(cwd: string): string | undefined;
41
+ /** Number of writers waiting behind the holder. */
42
+ queueDepth(cwd: string): number;
43
+ }
44
+
45
+ /** Resolve to a stable identity for a workspace. A missing dir falls back to an absolute path. */
46
+ export function workspaceKey(cwd: string): string {
47
+ try {
48
+ return realpathSync(cwd);
49
+ } catch {
50
+ return resolve(cwd);
51
+ }
52
+ }
53
+
54
+ interface Waiter {
55
+ holder: string;
56
+ grant: (release: ReleaseWriteLock) => void;
57
+ reject: (err: unknown) => void;
58
+ /** Detaches the abort listener once the waiter settles, either way. */
59
+ cleanup: () => void;
60
+ }
61
+
62
+ interface LockEntry {
63
+ holder: string;
64
+ waiters: Waiter[];
65
+ }
66
+
67
+ /**
68
+ * Create an independent lock table. The registry takes one as a dependency so tests get a
69
+ * clean table instead of racing on module-level state.
70
+ */
71
+ export function createWriteLockTable(): WriteLockTable {
72
+ const locks = new Map<string, LockEntry>();
73
+
74
+ function handOff(key: string, entry: LockEntry): void {
75
+ const next = entry.waiters.shift();
76
+ if (!next) {
77
+ locks.delete(key);
78
+ return;
79
+ }
80
+ entry.holder = next.holder;
81
+ next.cleanup();
82
+ next.grant(makeRelease(key, entry));
83
+ }
84
+
85
+ function makeRelease(key: string, entry: LockEntry): ReleaseWriteLock {
86
+ let released = false;
87
+ return () => {
88
+ if (released) return;
89
+ released = true;
90
+ // Guard against a stale release from a previous generation of this key.
91
+ if (locks.get(key) !== entry) return;
92
+ handOff(key, entry);
93
+ };
94
+ }
95
+
96
+ return {
97
+ acquire(cwd, options) {
98
+ const key = workspaceKey(cwd);
99
+ const holder = options.holder ?? options.agent;
100
+ const signal = options.signal;
101
+
102
+ if (signal?.aborted) return Promise.reject(cancelled(options.agent));
103
+
104
+ const entry = locks.get(key);
105
+ if (!entry) {
106
+ const fresh: LockEntry = { holder, waiters: [] };
107
+ locks.set(key, fresh);
108
+ return Promise.resolve(makeRelease(key, fresh));
109
+ }
110
+ if (!options.queue) return Promise.reject(workspaceBusy(options.agent, entry.holder));
111
+
112
+ const held = entry;
113
+ return new Promise<ReleaseWriteLock>((grant, reject) => {
114
+ const onAbort = (): void => {
115
+ const i = held.waiters.indexOf(waiter);
116
+ if (i >= 0) held.waiters.splice(i, 1);
117
+ waiter.cleanup();
118
+ reject(cancelled(options.agent));
119
+ };
120
+ const waiter: Waiter = {
121
+ holder,
122
+ grant,
123
+ reject,
124
+ cleanup: () => signal?.removeEventListener("abort", onAbort),
125
+ };
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ held.waiters.push(waiter);
128
+ });
129
+ },
130
+ isHeld(cwd) {
131
+ return locks.has(workspaceKey(cwd));
132
+ },
133
+ holder(cwd) {
134
+ return locks.get(workspaceKey(cwd))?.holder;
135
+ },
136
+ queueDepth(cwd) {
137
+ return locks.get(workspaceKey(cwd))?.waiters.length ?? 0;
138
+ },
139
+ };
140
+ }
141
+
142
+ /** Process-wide table, used when no table is injected. */
143
+ const sharedTable = createWriteLockTable();
144
+
145
+ /** Acquire the shared workspace write lock. Release in a `finally`. */
146
+ export function acquireWriteLock(cwd: string, options: AcquireWriteLockOptions): Promise<ReleaseWriteLock> {
147
+ return sharedTable.acquire(cwd, options);
148
+ }
149
+
150
+ /** True while a writer holds this workspace in the shared table. */
151
+ export function isWriteLockHeld(cwd: string): boolean {
152
+ return sharedTable.isHeld(cwd);
153
+ }
154
+
155
+ /** Label of the shared table's current holder for this workspace, if any. */
156
+ export function writeLockHolder(cwd: string): string | undefined {
157
+ return sharedTable.holder(cwd);
158
+ }