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,379 @@
1
+ /**
2
+ * In-memory, session-scoped run registry (T-401/T-403/T-409, _spec/08).
3
+ *
4
+ * Everything the registry needs from the outside world is injected, so it is host-agnostic
5
+ * and unit-testable: no `ctx`, no real child processes, no raw timers beyond the bounded
6
+ * ones in `wait`/`shutdown`. UI ticking belongs to the command layer (`ctx.setInterval`),
7
+ * not here.
8
+ */
9
+ import type { AgentName, AgentRequest, AgentResult, ExternalAgent } from "../agents/types.ts";
10
+ import type { MultiHarnessConfig } from "../config/schema.ts";
11
+ import { AgentError, cancelled } from "../process/process-error.ts";
12
+ import { summarize } from "../routing/handoff.ts";
13
+ import { createWriteLockTable, type ReleaseWriteLock, type WriteLockTable } from "./lock.ts";
14
+ import { createRingBuffer } from "./ring-buffer.ts";
15
+ import { isTerminal, type Run, type RunRegistry, type RunStatus, type RunView, type StartRunInput } from "./types.ts";
16
+
17
+ export interface RunRegistryDeps {
18
+ /** Read on every start so a live config reload is picked up without rebuilding the registry. */
19
+ config: () => MultiHarnessConfig;
20
+ /** Adapter factory. Injected so tests never spawn a real CLI. */
21
+ agentFor: (agent: AgentName) => ExternalAgent;
22
+ /** Injectable clock; tests freeze it. Defaults to `Date.now`. */
23
+ now?: () => number;
24
+ /** Injectable lock table; defaults to a private one per registry. */
25
+ lock?: WriteLockTable;
26
+ /** Injectable output buffer factory — tests use it to simulate a failing progress path. */
27
+ createBuffer?: (maxBytes: number) => import("./types.ts").RingBuffer;
28
+ /**
29
+ * Worker session to resume for `(agent, cwd)` when `continueSession` is not false.
30
+ * The session store (T-407) wires this in; without it every run starts fresh.
31
+ */
32
+ resolveSessionId?: (agent: AgentName, cwd: string) => string | undefined;
33
+ /** Called when a run reports a worker session id, so the store can persist the mapping. */
34
+ onWorkerSession?: (agent: AgentName, cwd: string, sessionId: string) => void;
35
+ }
36
+
37
+ const ID_ALPHABET = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/0/1 — these ids get retyped by hand
38
+
39
+ /** Registry-private fields; the shared `Run` contract in types.ts stays untouched. */
40
+ interface InternalRun extends Run {
41
+ /** Worker session to resume, resolved at `start` from the injected session store. */
42
+ sessionSeed?: string;
43
+ /** Per-call model override. */
44
+ model?: string;
45
+ }
46
+
47
+ /** Internal bookkeeping that must not leak into the shared `Run` shape. */
48
+ interface RunSlot {
49
+ run: InternalRun;
50
+ /** Monotonic insertion order; breaks `startedAt` ties under a frozen clock. */
51
+ seq: number;
52
+ /** Resolves when the run reaches a terminal status. Never rejects. */
53
+ done: Promise<void>;
54
+ settle: () => void;
55
+ /** True once the run has taken a concurrency slot. */
56
+ launched: boolean;
57
+ }
58
+
59
+ export function createRunRegistry(deps: RunRegistryDeps): RunRegistry {
60
+ const now = deps.now ?? (() => Date.now());
61
+ const lock = deps.lock ?? createWriteLockTable();
62
+ const makeBuffer = deps.createBuffer ?? createRingBuffer;
63
+
64
+ const slots = new Map<string, RunSlot>();
65
+ const listeners = new Set<(run: RunView) => void>();
66
+ /** FIFO of run ids waiting for a concurrency slot. */
67
+ const pending: string[] = [];
68
+ let active = 0;
69
+ let seq = 0;
70
+ let focusedId: string | undefined;
71
+
72
+ function newId(): string {
73
+ for (let length = 3; ; length++) {
74
+ for (let attempt = 0; attempt < 32; attempt++) {
75
+ let id = "r";
76
+ for (let i = 0; i < length; i++) id += ID_ALPHABET[Math.floor(Math.random() * ID_ALPHABET.length)];
77
+ if (!slots.has(id)) return id;
78
+ }
79
+ }
80
+ }
81
+
82
+ function toView(run: Run): RunView {
83
+ const view: RunView = {
84
+ id: run.id,
85
+ agent: run.agent,
86
+ mode: run.mode,
87
+ summary: run.summary,
88
+ task: run.task,
89
+ cwd: run.cwd,
90
+ readOnly: run.readOnly,
91
+ status: run.status,
92
+ startedAt: run.startedAt,
93
+ endedAt: run.endedAt,
94
+ elapsedMs: (run.endedAt ?? now()) - run.startedAt,
95
+ phase: run.phase,
96
+ workerSessionId: run.workerSessionId,
97
+ background: run.background,
98
+ };
99
+ if (run.result) {
100
+ view.output = run.result.output;
101
+ if (run.result.metadata) view.metadata = run.result.metadata;
102
+ }
103
+ if (run.error) {
104
+ view.errorCode = run.error.code;
105
+ view.errorMessage = run.error.message;
106
+ }
107
+ return view;
108
+ }
109
+
110
+ /** A listener that throws is a UI bug; it must never take a run — or the registry — down. */
111
+ function emit(run: Run): void {
112
+ if (listeners.size === 0) return;
113
+ const view = toView(run);
114
+ for (const listener of listeners) {
115
+ try {
116
+ listener(view);
117
+ } catch {
118
+ // Swallowed on purpose: presentation cannot fail execution.
119
+ }
120
+ }
121
+ }
122
+
123
+ function finish(slot: RunSlot, status: RunStatus, patch: { result?: AgentResult; error?: AgentError; phase?: string }): void {
124
+ const { run } = slot;
125
+ if (isTerminal(run.status)) return;
126
+ run.status = status;
127
+ run.endedAt = now();
128
+ if (patch.result) run.result = patch.result;
129
+ if (patch.error) run.error = patch.error;
130
+ run.phase = patch.phase ?? (status === "done" ? "completed" : status);
131
+ emit(run);
132
+ slot.settle();
133
+ }
134
+
135
+ function toAgentError(agent: AgentName, err: unknown): AgentError {
136
+ if (err instanceof AgentError) return err;
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ return new AgentError({ code: "PROCESS_FAILED", agent, message, cause: err });
139
+ }
140
+
141
+ /** Start as many queued runs as `maxConcurrentRuns` allows, oldest first. */
142
+ function pump(): void {
143
+ const max = Math.max(1, deps.config().concurrency.maxConcurrentRuns);
144
+ while (active < max && pending.length > 0) {
145
+ const id = pending.shift()!;
146
+ const slot = slots.get(id);
147
+ if (!slot || isTerminal(slot.run.status)) continue;
148
+ active++;
149
+ slot.launched = true;
150
+ // Detached by design — `execute` never rejects, but belt and braces: a rejection
151
+ // here would otherwise be an unhandled rejection that can tear down the host.
152
+ execute(slot).catch((err) => finish(slot, "failed", { error: toAgentError(slot.run.agent, err) }));
153
+ }
154
+ }
155
+
156
+ async function execute(slot: RunSlot): Promise<void> {
157
+ const { run } = slot;
158
+ let release: ReleaseWriteLock | undefined;
159
+ try {
160
+ if (run.controller.signal.aborted) {
161
+ finish(slot, "cancelled", { error: cancelled(run.agent) });
162
+ return;
163
+ }
164
+
165
+ const config = deps.config();
166
+ if (!run.readOnly) {
167
+ // Writers serialize per workspace; readers never touch the lock (spec 08 table).
168
+ run.phase = "waiting for workspace";
169
+ emit(run);
170
+ release = await lock.acquire(run.cwd, {
171
+ agent: run.agent,
172
+ queue: config.concurrency.writerQueue,
173
+ holder: `${run.agent} ${run.id}`,
174
+ signal: run.controller.signal,
175
+ });
176
+ }
177
+
178
+ if (run.controller.signal.aborted) {
179
+ finish(slot, "cancelled", { error: cancelled(run.agent) });
180
+ return;
181
+ }
182
+
183
+ run.status = "running";
184
+ run.spawnedAt = now();
185
+ run.phase = "starting";
186
+ emit(run);
187
+
188
+ const request: AgentRequest = {
189
+ agent: run.agent,
190
+ task: run.task,
191
+ cwd: run.cwd,
192
+ mode: run.mode,
193
+ readOnly: run.readOnly,
194
+ timeoutMs: config[run.agent].timeoutMs,
195
+ };
196
+ if (run.sessionSeed) request.sessionId = run.sessionSeed;
197
+ if (run.model) request.model = run.model;
198
+
199
+ const result = await deps.agentFor(run.agent).run(request, {
200
+ signal: run.controller.signal,
201
+ onProgress: (event) => {
202
+ run.phase = event.phase;
203
+ run.output.push(event.detail ? `${event.phase}: ${event.detail}\n` : `${event.phase}\n`);
204
+ emit(run);
205
+ },
206
+ });
207
+
208
+ if (result.sessionId) {
209
+ run.workerSessionId = result.sessionId;
210
+ deps.onWorkerSession?.(run.agent, run.cwd, result.sessionId);
211
+ }
212
+ run.output.push(result.output.endsWith("\n") ? result.output : `${result.output}\n`);
213
+
214
+ if (run.controller.signal.aborted && !result.success) {
215
+ finish(slot, "cancelled", { result, error: cancelled(run.agent) });
216
+ } else if (result.success) {
217
+ finish(slot, "done", { result });
218
+ } else {
219
+ finish(slot, "failed", { result, error: toAgentError(run.agent, new Error(result.stderr || "the agent reported failure")) });
220
+ }
221
+ } catch (err) {
222
+ const error = toAgentError(run.agent, err);
223
+ finish(slot, error.code === "CANCELLED" || run.controller.signal.aborted ? "cancelled" : "failed", { error });
224
+ } finally {
225
+ // Always, including cancellation and throws — a leaked lock wedges the workspace.
226
+ release?.();
227
+ active = Math.max(0, active - 1);
228
+ pump();
229
+ }
230
+ }
231
+
232
+ return {
233
+ start(input: StartRunInput): RunView {
234
+ const config = deps.config();
235
+ const id = newId();
236
+ const run: InternalRun = {
237
+ id,
238
+ agent: input.agent,
239
+ mode: input.mode,
240
+ task: input.task,
241
+ summary: input.summary ?? summarize(input.task),
242
+ cwd: input.cwd,
243
+ readOnly: input.readOnly,
244
+ status: "queued",
245
+ startedAt: now(),
246
+ phase: "queued",
247
+ controller: new AbortController(),
248
+ output: makeBuffer(config.limits.ringBufferBytes),
249
+ background: input.background ?? false,
250
+ };
251
+ if (input.model) run.model = input.model;
252
+ if (input.continueSession !== false) {
253
+ run.sessionSeed = deps.resolveSessionId?.(input.agent, input.cwd);
254
+ }
255
+
256
+ let settle!: () => void;
257
+ const done = new Promise<void>((resolve) => {
258
+ settle = resolve;
259
+ });
260
+ const slot: RunSlot = { run, seq: seq++, done, settle, launched: false };
261
+ slots.set(id, slot);
262
+ pending.push(id);
263
+ emit(run);
264
+ // Kicks off execution; `start` itself never waits for the worker.
265
+ pump();
266
+ return toView(run);
267
+ },
268
+
269
+ list(): RunView[] {
270
+ return [...slots.values()]
271
+ .sort((a, b) => b.run.startedAt - a.run.startedAt || b.seq - a.seq)
272
+ .map((s) => toView(s.run));
273
+ },
274
+
275
+ get(id: string): RunView | undefined {
276
+ const slot = slots.get(id);
277
+ return slot ? toView(slot.run) : undefined;
278
+ },
279
+
280
+ async cancel(id: string): Promise<boolean> {
281
+ const slot = slots.get(id);
282
+ if (!slot || isTerminal(slot.run.status)) return false;
283
+ slot.run.controller.abort();
284
+ if (!slot.launched) {
285
+ // Never spawned: settle it here, the pump would otherwise skip it silently.
286
+ const i = pending.indexOf(id);
287
+ if (i >= 0) pending.splice(i, 1);
288
+ finish(slot, "cancelled", { error: cancelled(slot.run.agent) });
289
+ return true;
290
+ }
291
+ await withDeadline(slot.done, deps.config().limits.killGraceMs);
292
+ // The child ignored the grace period; the registry still tells the truth.
293
+ finish(slot, "cancelled", { error: cancelled(slot.run.agent) });
294
+ return true;
295
+ },
296
+
297
+ async wait(id: string, timeoutMs?: number): Promise<RunView | undefined> {
298
+ const slot = slots.get(id);
299
+ if (!slot) return undefined;
300
+ if (isTerminal(slot.run.status)) return toView(slot.run);
301
+ if (timeoutMs === undefined) {
302
+ await slot.done;
303
+ } else {
304
+ await withDeadline(slot.done, timeoutMs);
305
+ }
306
+ // On timeout this is deliberately the *live* view — callers poll or re-wait.
307
+ return toView(slot.run);
308
+ },
309
+
310
+ focus(id: string | undefined): void {
311
+ if (id !== undefined && !slots.has(id)) return;
312
+ focusedId = id;
313
+ },
314
+
315
+ focused(): string | undefined {
316
+ return focusedId;
317
+ },
318
+
319
+ clearFinished(): number {
320
+ let dropped = 0;
321
+ for (const [id, slot] of [...slots]) {
322
+ if (!isTerminal(slot.run.status)) continue;
323
+ slots.delete(id);
324
+ if (focusedId === id) focusedId = undefined;
325
+ dropped++;
326
+ }
327
+ return dropped;
328
+ },
329
+
330
+ tail(id: string, lines?: number): string[] {
331
+ return slots.get(id)?.run.output.lines(lines) ?? [];
332
+ },
333
+
334
+ subscribe(listener: (run: RunView) => void): () => void {
335
+ listeners.add(listener);
336
+ return () => {
337
+ listeners.delete(listener);
338
+ };
339
+ },
340
+
341
+ async shutdown(): Promise<void> {
342
+ const live = [...slots.values()].filter((s) => !isTerminal(s.run.status));
343
+ for (const slot of live) {
344
+ slot.run.controller.abort();
345
+ if (!slot.launched) {
346
+ const i = pending.indexOf(slot.run.id);
347
+ if (i >= 0) pending.splice(i, 1);
348
+ finish(slot, "cancelled", { error: cancelled(slot.run.agent) });
349
+ }
350
+ }
351
+ await withDeadline(
352
+ Promise.all(live.map((s) => s.done)).then(() => undefined),
353
+ deps.config().limits.killGraceMs,
354
+ );
355
+ // Anything still alive after the grace period is recorded as cancelled anyway: no
356
+ // child may outlive the OMP session, and the registry must not lie about it either.
357
+ for (const slot of live) finish(slot, "cancelled", { error: cancelled(slot.run.agent) });
358
+ },
359
+ };
360
+ }
361
+
362
+ /** Resolve when `promise` settles or `ms` elapses, whichever comes first. Never rejects. */
363
+ function withDeadline(promise: Promise<unknown>, ms: number): Promise<void> {
364
+ return new Promise<void>((resolve) => {
365
+ const timer = setTimeout(resolve, Math.max(0, ms));
366
+ // `unref` keeps a pending deadline from holding the process open in tests/CLIs.
367
+ (timer as unknown as { unref?: () => void }).unref?.();
368
+ promise.then(
369
+ () => {
370
+ clearTimeout(timer);
371
+ resolve();
372
+ },
373
+ () => {
374
+ clearTimeout(timer);
375
+ resolve();
376
+ },
377
+ );
378
+ });
379
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Byte-capped, line-aware live output buffer (T-402, _spec/08).
3
+ *
4
+ * Worker output is unbounded; the attach view is not. We cap by **UTF-8 bytes** (never
5
+ * `String.length`, which lies about anything non-ASCII) and evict whole lines from the
6
+ * front, so the oldest thing the user sees is always a complete line rather than the tail
7
+ * of one.
8
+ */
9
+ import type { RingBuffer } from "./types.ts";
10
+
11
+ /**
12
+ * Keep at most `maxBytes` bytes from the end of `text`, starting at a valid UTF-8
13
+ * character boundary — slicing a Buffer mid-sequence would decode as U+FFFD.
14
+ */
15
+ function tailBytes(text: string, maxBytes: number): string {
16
+ const buf = Buffer.from(text, "utf8");
17
+ if (buf.length <= maxBytes) return text;
18
+ let start = buf.length - maxBytes;
19
+ // 0b10xxxxxx is a continuation byte: walk forward until we are on a lead byte.
20
+ while (start < buf.length && (buf[start]! & 0xc0) === 0x80) start++;
21
+ return buf.toString("utf8", start);
22
+ }
23
+
24
+ /**
25
+ * Create a ring buffer holding at most `maxBytes` UTF-8 bytes.
26
+ *
27
+ * Eviction drops whole lines from the front. A single line longer than the whole cap
28
+ * cannot be evicted line-wise, so it is hard-truncated from the front (at a character
29
+ * boundary) — the alternative is blowing the cap, which is worse.
30
+ */
31
+ export function createRingBuffer(maxBytes: number): RingBuffer {
32
+ const cap = Math.max(1, Math.floor(maxBytes));
33
+ let text = "";
34
+ let bytes = 0;
35
+ let droppedBytes = 0;
36
+
37
+ function evict(): void {
38
+ while (bytes > cap) {
39
+ const nl = text.indexOf("\n");
40
+ if (nl === -1) break;
41
+ const removedBytes = Buffer.byteLength(text.slice(0, nl + 1), "utf8");
42
+ text = text.slice(nl + 1);
43
+ bytes -= removedBytes;
44
+ droppedBytes += removedBytes;
45
+ }
46
+ // Left with one over-long partial line: keep its tail, honestly counting the loss.
47
+ if (bytes > cap) {
48
+ const kept = tailBytes(text, cap);
49
+ const keptBytes = Buffer.byteLength(kept, "utf8");
50
+ droppedBytes += bytes - keptBytes;
51
+ text = kept;
52
+ bytes = keptBytes;
53
+ }
54
+ }
55
+
56
+ return {
57
+ push(chunk: string): void {
58
+ if (!chunk) return;
59
+ text += chunk;
60
+ bytes += Buffer.byteLength(chunk, "utf8");
61
+ evict();
62
+ },
63
+ lines(limit?: number): string[] {
64
+ if (!text) return [];
65
+ const all = text.split("\n");
66
+ // A trailing newline yields an empty final element — that is a terminator, not a line.
67
+ if (all[all.length - 1] === "") all.pop();
68
+ if (limit === undefined) return all;
69
+ return all.slice(Math.max(0, all.length - Math.max(0, limit)));
70
+ },
71
+ text(): string {
72
+ return text;
73
+ },
74
+ get bytes(): number {
75
+ return bytes;
76
+ },
77
+ get droppedBytes(): number {
78
+ return droppedBytes;
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Run registry contract. See _spec/08-sessions-and-parallelism.md.
3
+ *
4
+ * A *run* is one invocation of one external agent (one child process). Many may be alive
5
+ * at once. This file is the shared contract between the registry, the `/sessions` command,
6
+ * the `ask_*` tools, and the `agent_runs` tool — change it deliberately.
7
+ */
8
+ import type { AgentMode, AgentName, AgentResult } from "../agents/types.ts";
9
+ import type { AgentError } from "../process/process-error.ts";
10
+
11
+ export type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
12
+
13
+ /** A run is terminal once it can never produce more output. */
14
+ export const TERMINAL_STATUSES: readonly RunStatus[] = ["done", "failed", "cancelled"];
15
+
16
+ export function isTerminal(status: RunStatus): boolean {
17
+ return status === "done" || status === "failed" || status === "cancelled";
18
+ }
19
+
20
+ /** Byte-capped, line-aware live output buffer (T-402). */
21
+ export interface RingBuffer {
22
+ /** Append raw output. Never throws; over-cap content is dropped from the front. */
23
+ push(chunk: string): void;
24
+ /** Most recent `limit` complete lines, oldest first. */
25
+ lines(limit?: number): string[];
26
+ /** Everything currently retained, joined. */
27
+ text(): string;
28
+ /** Bytes currently retained. */
29
+ readonly bytes: number;
30
+ /** Bytes discarded to stay under the cap — surfaced so the UI can say so. */
31
+ readonly droppedBytes: number;
32
+ }
33
+
34
+ /** Live run. Held only inside the registry; everything else consumes `RunView`. */
35
+ export interface Run {
36
+ id: string;
37
+ agent: AgentName;
38
+ mode?: AgentMode;
39
+ /** Full task text as handed to the worker. */
40
+ task: string;
41
+ /** One line, for lists. */
42
+ summary: string;
43
+ cwd: string;
44
+ readOnly: boolean;
45
+ status: RunStatus;
46
+ /** Wall clock at `start()`, including any time spent `queued`. */
47
+ startedAt: number;
48
+ /** Wall clock when the child process actually began. */
49
+ spawnedAt?: number;
50
+ endedAt?: number;
51
+ /** Latest progress phase, e.g. "running tests". */
52
+ phase: string;
53
+ workerSessionId?: string;
54
+ /** Resolved per-call model override, if any. Surfaced so `/sessions` can show it. */
55
+ model?: string;
56
+ result?: AgentResult;
57
+ error?: AgentError;
58
+ controller: AbortController;
59
+ output: RingBuffer;
60
+ /** True when this run is the supervisor's foreground call rather than a background run. */
61
+ background: boolean;
62
+ }
63
+
64
+ /** Immutable snapshot handed to UI and tools. Never exposes the controller or buffer. */
65
+ export interface RunView {
66
+ id: string;
67
+ agent: AgentName;
68
+ mode?: AgentMode;
69
+ summary: string;
70
+ task: string;
71
+ cwd: string;
72
+ readOnly: boolean;
73
+ status: RunStatus;
74
+ startedAt: number;
75
+ endedAt?: number;
76
+ elapsedMs: number;
77
+ phase: string;
78
+ workerSessionId?: string;
79
+ model?: string;
80
+ background: boolean;
81
+ /** Present once the run finished successfully. */
82
+ output?: string;
83
+ /** Present once the run failed. `code` is an `AgentErrorCode`. */
84
+ errorCode?: string;
85
+ errorMessage?: string;
86
+ /**
87
+ * The adapter's own `AgentResult.metadata`, verbatim. Carries claims only the adapter can
88
+ * make honestly — notably `readOnlyEnforced`, which reflects the argv actually built rather
89
+ * than what the caller asked for.
90
+ */
91
+ metadata?: Record<string, unknown>;
92
+ }
93
+
94
+ export interface StartRunInput {
95
+ agent: AgentName;
96
+ /** Final worker-facing text (already run through `buildHandoff`). */
97
+ task: string;
98
+ /** Short display summary; defaults to `summarize(task)`. */
99
+ summary?: string;
100
+ cwd: string;
101
+ mode?: AgentMode;
102
+ readOnly: boolean;
103
+ model?: string;
104
+ /** Resume the mapped worker session for (OMP session, cwd). Default true. */
105
+ continueSession?: boolean;
106
+ /**
107
+ * Explicit worker session id to resume. Supplied by the session store; when absent the
108
+ * registry asks its `resolveSessionId` dep. `continueSession` says *whether* to resume,
109
+ * this says *which* — the two are not interchangeable.
110
+ */
111
+ resumeSessionId?: string;
112
+ /** Fork rather than continue the resumed session (parallel same-agent, same-cwd). */
113
+ fork?: boolean;
114
+ /** Background runs are not awaited by their caller. */
115
+ background?: boolean;
116
+ }
117
+
118
+ export interface RunRegistry {
119
+ /**
120
+ * Accept a run and return it immediately (status `queued` or `running`). Returns
121
+ * synchronously and does NOT wait for the worker — await `wait(id)` for the result.
122
+ */
123
+ start(input: StartRunInput): RunView;
124
+ list(): RunView[];
125
+ get(id: string): RunView | undefined;
126
+ /** Idempotent: cancelling an already-terminal run is a no-op returning false. */
127
+ cancel(id: string): Promise<boolean>;
128
+ /** Resolve when the run reaches a terminal status, or after `timeoutMs` with the live view. */
129
+ wait(id: string, timeoutMs?: number): Promise<RunView | undefined>;
130
+ /** Presentation only — never pauses, throttles, or reorders any run. */
131
+ focus(id: string | undefined): void;
132
+ focused(): string | undefined;
133
+ /** Drop terminal runs from the list. Returns how many were dropped. */
134
+ clearFinished(): number;
135
+ /** Live tail of a run's output. */
136
+ tail(id: string, lines?: number): string[];
137
+ /** Subscribe to any change (status, phase, output). Returns an unsubscribe function. */
138
+ subscribe(listener: (run: RunView) => void): () => void;
139
+ /** Cancel every non-terminal run and await termination. Bounded by `killGraceMs`. */
140
+ shutdown(): Promise<void>;
141
+ }