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,249 @@
1
+ /** multiHarness configuration — shape, defaults, and normalization. See _spec/09-config.md. */
2
+
3
+ export type AgentName = "codex" | "claude";
4
+ export type RoutingMode = "model" | "rules";
5
+ export type AgentMode = "analyze" | "plan" | "implement" | "debug" | "review" | "test";
6
+
7
+ export interface AgentConfig {
8
+ enabled: boolean;
9
+ executable: string;
10
+ timeoutMs: number;
11
+ /** null → the CLI's own config decides the model (the default promise of this project). */
12
+ model: string | null;
13
+ additionalDirs: string[];
14
+ /** Claude only: use --permission-mode acceptEdits for write runs. */
15
+ acceptEdits: boolean;
16
+ }
17
+
18
+ export interface RoutingConfig {
19
+ default: "auto" | AgentName;
20
+ mode: RoutingMode;
21
+ model: string;
22
+ modelFallbacks: string[];
23
+ modelTimeoutMs: number;
24
+ modeMap: Record<AgentMode, AgentName>;
25
+ promptGuidance: boolean;
26
+ }
27
+
28
+ export interface ConcurrencyConfig {
29
+ maxConcurrentRuns: number;
30
+ allowParallelReads: boolean;
31
+ allowParallelWrites: boolean;
32
+ writerQueue: boolean;
33
+ }
34
+
35
+ export interface LimitsConfig {
36
+ maxOutputChars: number;
37
+ maxHandoffChars: number;
38
+ ringBufferBytes: number;
39
+ killGraceMs: number;
40
+ }
41
+
42
+ export interface MultiHarnessConfig {
43
+ enabled: boolean;
44
+ codex: AgentConfig;
45
+ claude: AgentConfig;
46
+ routing: RoutingConfig;
47
+ sessions: { persist: boolean };
48
+ concurrency: ConcurrencyConfig;
49
+ limits: LimitsConfig;
50
+ debug: boolean;
51
+ }
52
+
53
+ export const DEFAULTS: MultiHarnessConfig = {
54
+ enabled: true,
55
+ codex: { enabled: true, executable: "codex", timeoutMs: 1_800_000, model: null, additionalDirs: [], acceptEdits: false },
56
+ claude: { enabled: true, executable: "claude", timeoutMs: 1_800_000, model: null, additionalDirs: [], acceptEdits: false },
57
+ routing: {
58
+ default: "auto",
59
+ mode: "model",
60
+ model: "@smol",
61
+ modelFallbacks: ["anthropic/claude-haiku-4-5", "openai/gpt-5.2-mini", "google/gemini-2.5-flash"],
62
+ modelTimeoutMs: 5_000,
63
+ modeMap: { plan: "claude", analyze: "claude", review: "claude", implement: "codex", debug: "codex", test: "codex" },
64
+ promptGuidance: true,
65
+ },
66
+ sessions: { persist: true },
67
+ concurrency: { maxConcurrentRuns: 4, allowParallelReads: true, allowParallelWrites: false, writerQueue: true },
68
+ limits: { maxOutputChars: 32_000, maxHandoffChars: 4_000, ringBufferBytes: 1_048_576, killGraceMs: 5_000 },
69
+ debug: false,
70
+ };
71
+
72
+ export interface NormalizeResult {
73
+ config: MultiHarnessConfig;
74
+ /** Human-readable problems. Never fatal — bad values fall back to defaults. */
75
+ warnings: string[];
76
+ }
77
+
78
+ const MODEL_TOKEN = /^[A-Za-z0-9._:@/-]{1,120}$/;
79
+
80
+ /** A per-call or configured model override must look like a model token before it reaches argv. */
81
+ export function isValidModelToken(value: string): boolean {
82
+ return MODEL_TOKEN.test(value);
83
+ }
84
+
85
+ function pickBoolean(raw: unknown, fallback: boolean, path: string, warnings: string[]): boolean {
86
+ if (raw === undefined || raw === null) return fallback;
87
+ if (typeof raw === "boolean") return raw;
88
+ warnings.push(`${path}: expected boolean, got ${typeof raw} — using ${fallback}`);
89
+ return fallback;
90
+ }
91
+
92
+ function pickNumber(raw: unknown, fallback: number, path: string, warnings: string[], min = 1): number {
93
+ if (raw === undefined || raw === null) return fallback;
94
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= min) return raw;
95
+ warnings.push(`${path}: expected number >= ${min}, got ${JSON.stringify(raw)} — using ${fallback}`);
96
+ return fallback;
97
+ }
98
+
99
+ function pickString(raw: unknown, fallback: string, path: string, warnings: string[]): string {
100
+ if (raw === undefined || raw === null) return fallback;
101
+ if (typeof raw === "string" && raw.length > 0) return raw;
102
+ warnings.push(`${path}: expected non-empty string — using ${fallback}`);
103
+ return fallback;
104
+ }
105
+
106
+ function pickModel(raw: unknown, path: string, warnings: string[]): string | null {
107
+ if (raw === undefined || raw === null) return null;
108
+ if (typeof raw === "string" && isValidModelToken(raw)) return raw;
109
+ warnings.push(`${path}: not a valid model token — ignoring, the CLI's own config will decide`);
110
+ return null;
111
+ }
112
+
113
+ function pickStringArray(raw: unknown, fallback: string[], path: string, warnings: string[]): string[] {
114
+ if (raw === undefined || raw === null) return fallback;
115
+ if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) return raw as string[];
116
+ warnings.push(`${path}: expected string[] — using default`);
117
+ return fallback;
118
+ }
119
+
120
+ function obj(raw: unknown): Record<string, unknown> {
121
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
122
+ }
123
+
124
+ const KNOWN_TOP = new Set(["enabled", "codex", "claude", "routing", "sessions", "concurrency", "limits", "debug"]);
125
+
126
+ function agentConfig(raw: unknown, d: AgentConfig, path: string, warnings: string[]): AgentConfig {
127
+ const r = obj(raw);
128
+ return {
129
+ enabled: pickBoolean(r.enabled, d.enabled, `${path}.enabled`, warnings),
130
+ executable: pickString(r.executable, d.executable, `${path}.executable`, warnings),
131
+ timeoutMs: pickNumber(r.timeoutMs, d.timeoutMs, `${path}.timeoutMs`, warnings, 1_000),
132
+ model: pickModel(r.model, `${path}.model`, warnings),
133
+ additionalDirs: pickStringArray(r.additionalDirs, d.additionalDirs, `${path}.additionalDirs`, warnings),
134
+ acceptEdits: pickBoolean(r.acceptEdits, d.acceptEdits, `${path}.acceptEdits`, warnings),
135
+ };
136
+ }
137
+
138
+ /** Merge a raw `multiHarness` block over the defaults. Unknown keys warn; bad values fall back. */
139
+ export function normalizeConfig(raw: unknown): NormalizeResult {
140
+ const warnings: string[] = [];
141
+ const r = obj(raw);
142
+
143
+ for (const key of Object.keys(r)) {
144
+ if (!KNOWN_TOP.has(key)) warnings.push(`multiHarness.${key}: unknown option — ignored`);
145
+ }
146
+
147
+ const routingRaw = obj(r.routing);
148
+ const modeMapRaw = obj(routingRaw.modeMap);
149
+ const modeMap = { ...DEFAULTS.routing.modeMap };
150
+ for (const [mode, agent] of Object.entries(modeMapRaw)) {
151
+ if (!(mode in modeMap)) {
152
+ warnings.push(`multiHarness.routing.modeMap.${mode}: unknown mode — ignored`);
153
+ } else if (agent === "codex" || agent === "claude") {
154
+ modeMap[mode as AgentMode] = agent;
155
+ } else {
156
+ warnings.push(`multiHarness.routing.modeMap.${mode}: expected "codex" or "claude" — keeping default`);
157
+ }
158
+ }
159
+
160
+ const routingDefaultRaw = routingRaw.default;
161
+ const routingDefault =
162
+ routingDefaultRaw === "auto" || routingDefaultRaw === "codex" || routingDefaultRaw === "claude"
163
+ ? routingDefaultRaw
164
+ : (routingDefaultRaw === undefined || routingDefaultRaw === null
165
+ ? DEFAULTS.routing.default
166
+ : (warnings.push('multiHarness.routing.default: expected "auto" | "codex" | "claude" — using "auto"'),
167
+ DEFAULTS.routing.default));
168
+
169
+ const routingModeRaw = routingRaw.mode;
170
+ const routingMode: RoutingMode =
171
+ routingModeRaw === "model" || routingModeRaw === "rules"
172
+ ? routingModeRaw
173
+ : (routingModeRaw === undefined || routingModeRaw === null
174
+ ? DEFAULTS.routing.mode
175
+ : (warnings.push('multiHarness.routing.mode: expected "model" | "rules" — using "model"'), DEFAULTS.routing.mode));
176
+
177
+ const concurrencyRaw = obj(r.concurrency);
178
+ const limitsRaw = obj(r.limits);
179
+
180
+ return {
181
+ warnings,
182
+ config: {
183
+ enabled: pickBoolean(r.enabled, DEFAULTS.enabled, "multiHarness.enabled", warnings),
184
+ codex: agentConfig(r.codex, DEFAULTS.codex, "multiHarness.codex", warnings),
185
+ claude: agentConfig(r.claude, DEFAULTS.claude, "multiHarness.claude", warnings),
186
+ routing: {
187
+ default: routingDefault,
188
+ mode: routingMode,
189
+ model: pickString(routingRaw.model, DEFAULTS.routing.model, "multiHarness.routing.model", warnings),
190
+ modelFallbacks: pickStringArray(
191
+ routingRaw.modelFallbacks,
192
+ DEFAULTS.routing.modelFallbacks,
193
+ "multiHarness.routing.modelFallbacks",
194
+ warnings,
195
+ ),
196
+ modelTimeoutMs: pickNumber(
197
+ routingRaw.modelTimeoutMs,
198
+ DEFAULTS.routing.modelTimeoutMs,
199
+ "multiHarness.routing.modelTimeoutMs",
200
+ warnings,
201
+ 100,
202
+ ),
203
+ modeMap,
204
+ promptGuidance: pickBoolean(
205
+ routingRaw.promptGuidance,
206
+ DEFAULTS.routing.promptGuidance,
207
+ "multiHarness.routing.promptGuidance",
208
+ warnings,
209
+ ),
210
+ },
211
+ sessions: {
212
+ persist: pickBoolean(obj(r.sessions).persist, DEFAULTS.sessions.persist, "multiHarness.sessions.persist", warnings),
213
+ },
214
+ concurrency: {
215
+ maxConcurrentRuns: pickNumber(
216
+ concurrencyRaw.maxConcurrentRuns,
217
+ DEFAULTS.concurrency.maxConcurrentRuns,
218
+ "multiHarness.concurrency.maxConcurrentRuns",
219
+ warnings,
220
+ ),
221
+ allowParallelReads: pickBoolean(
222
+ concurrencyRaw.allowParallelReads,
223
+ DEFAULTS.concurrency.allowParallelReads,
224
+ "multiHarness.concurrency.allowParallelReads",
225
+ warnings,
226
+ ),
227
+ allowParallelWrites: pickBoolean(
228
+ concurrencyRaw.allowParallelWrites,
229
+ DEFAULTS.concurrency.allowParallelWrites,
230
+ "multiHarness.concurrency.allowParallelWrites",
231
+ warnings,
232
+ ),
233
+ writerQueue: pickBoolean(
234
+ concurrencyRaw.writerQueue,
235
+ DEFAULTS.concurrency.writerQueue,
236
+ "multiHarness.concurrency.writerQueue",
237
+ warnings,
238
+ ),
239
+ },
240
+ limits: {
241
+ maxOutputChars: pickNumber(limitsRaw.maxOutputChars, DEFAULTS.limits.maxOutputChars, "multiHarness.limits.maxOutputChars", warnings, 500),
242
+ maxHandoffChars: pickNumber(limitsRaw.maxHandoffChars, DEFAULTS.limits.maxHandoffChars, "multiHarness.limits.maxHandoffChars", warnings, 200),
243
+ ringBufferBytes: pickNumber(limitsRaw.ringBufferBytes, DEFAULTS.limits.ringBufferBytes, "multiHarness.limits.ringBufferBytes", warnings, 4_096),
244
+ killGraceMs: pickNumber(limitsRaw.killGraceMs, DEFAULTS.limits.killGraceMs, "multiHarness.limits.killGraceMs", warnings, 100),
245
+ },
246
+ debug: pickBoolean(r.debug, DEFAULTS.debug, "multiHarness.debug", warnings),
247
+ },
248
+ };
249
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * omp-multi-harness — OMP supervises the Codex and Claude Code CLIs.
3
+ *
4
+ * Load phase is REGISTRATION ONLY: action methods (pi.sendMessage, …) throw
5
+ * ExtensionRuntimeNotInitializedError if called here (_spec/01 §2). Anything that needs a
6
+ * live session happens in an event handler, a command, or a tool.
7
+ */
8
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
9
+ import { clearAvailabilityCache } from "./agents/availability.ts";
10
+ import { ClaudeAgent } from "./agents/claude.ts";
11
+ import { CodexAgent } from "./agents/codex.ts";
12
+ import type { AgentName, ExternalAgent } from "./agents/types.ts";
13
+ import { registerAgentsCommand } from "./commands/agents.ts";
14
+ import { registerDelegateCommand } from "./commands/delegate-command.ts";
15
+ import { registerHarnessSetupCommand } from "./commands/harness-setup.ts";
16
+ import { registerSessionsCommand } from "./commands/sessions.ts";
17
+ import { registerRoutingGuidance } from "./routing/prompt.ts";
18
+ import { createRunRegistry } from "./runs/registry.ts";
19
+ import type { RunRegistry } from "./runs/types.ts";
20
+ import { createSessionStore } from "./sessions/store.ts";
21
+ import { registerAgentRunsTool } from "./tools/agent-runs.ts";
22
+ import { registerAskAgentTool } from "./tools/ask-agent.ts";
23
+ import { registerDelegateTool } from "./tools/delegate.ts";
24
+ import { type LoadedConfig, loadConfig } from "./config/load.ts";
25
+ import { DEFAULTS, type MultiHarnessConfig } from "./config/schema.ts";
26
+
27
+ /** Cache key for the (agent, cwd) → worker session mapping. `agent` is a closed set with
28
+ * no colon, so a colon cannot be ambiguous against anything a path contributes. */
29
+ const mappingKey = (agent: AgentName, cwd: string): string => `${agent}:${cwd}`;
30
+
31
+ export default function multiHarness(pi: ExtensionAPI) {
32
+ pi.setLabel("Multi-Harness");
33
+
34
+ // Loaded eagerly so commands registered now have something to read; re-read per session
35
+ // because the working directory (and therefore the project config) can change.
36
+ let loaded: LoadedConfig = loadConfig(process.cwd());
37
+ const getConfig = (): MultiHarnessConfig => (loaded.config.enabled ? loaded.config : { ...DEFAULTS, enabled: false });
38
+ const getSources = (): string[] => loaded.sources;
39
+
40
+ const store = createSessionStore({ onWarning: (m) => pi.logger.warn?.(`[multi-harness] ${m}`) });
41
+
42
+ /**
43
+ * The registry resolves a session to resume *synchronously*, but the store is async (the
44
+ * agent dir resolves lazily). This warm cache bridges the two: filled once per session
45
+ * before any run can start, then updated in place as runs report their session ids.
46
+ */
47
+ const sessionIds = new Map<string, string>();
48
+ /** The OMP session id, known only once a session is live. */
49
+ let ompSessionId: string | undefined;
50
+
51
+ const agentFor = (agent: AgentName): ExternalAgent => {
52
+ const config = getConfig();
53
+ return agent === "codex" ? new CodexAgent(config.codex) : new ClaudeAgent(config.claude);
54
+ };
55
+
56
+ const newRegistry = (): RunRegistry =>
57
+ createRunRegistry({
58
+ config: getConfig,
59
+ agentFor,
60
+ resolveSessionId: (agent, cwd) => sessionIds.get(mappingKey(agent, cwd)),
61
+ onWorkerSession: (agent, cwd, sessionId) => {
62
+ sessionIds.set(mappingKey(agent, cwd), sessionId);
63
+ if (!ompSessionId || !getConfig().sessions.persist) return;
64
+ // Detached: persistence must never fail a run that already succeeded.
65
+ store
66
+ .record(ompSessionId, cwd, agent, sessionId)
67
+ .catch((e) => pi.logger.warn?.(`[multi-harness] session store: ${(e as Error).message}`));
68
+ },
69
+ });
70
+
71
+ // Created at load rather than on session_start so tools registered now can close over a
72
+ // getter that is always defined; session_start swaps in a fresh one per session.
73
+ let registry: RunRegistry = newRegistry();
74
+ const getRegistry = (): RunRegistry => registry;
75
+
76
+ registerAgentsCommand(pi, getConfig, getRegistry);
77
+ registerHarnessSetupCommand(pi, getConfig, getSources);
78
+ registerSessionsCommand({ pi, getConfig, getRegistry });
79
+ registerRoutingGuidance({ pi, getConfig });
80
+
81
+ registerAskAgentTool({ pi, agent: "codex", getConfig, getRegistry });
82
+ registerDelegateCommand({ pi, agent: "codex", getConfig, getRegistry });
83
+
84
+ registerAskAgentTool({ pi, agent: "claude", getConfig, getRegistry });
85
+ registerDelegateCommand({ pi, agent: "claude", getConfig, getRegistry });
86
+
87
+ registerDelegateTool({ pi, getConfig, getRegistry });
88
+ registerAgentRunsTool({ pi, getConfig, getRegistry });
89
+
90
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
91
+ loaded = loadConfig(ctx.cwd);
92
+ clearAvailabilityCache();
93
+
94
+ for (const warning of loaded.warnings) pi.logger.warn?.(`[multi-harness] ${warning}`);
95
+
96
+ if (!loaded.config.enabled) {
97
+ pi.logger.info?.("[multi-harness] disabled via multiHarness.enabled: false");
98
+ return;
99
+ }
100
+
101
+ // A previous session's runs must not outlive it, even if shutdown never fired.
102
+ await registry.shutdown().catch(() => {});
103
+ registry = newRegistry();
104
+
105
+ ompSessionId = ctx.sessionManager.getSessionId();
106
+ sessionIds.clear();
107
+ if (loaded.config.sessions.persist && ompSessionId) {
108
+ const mapping = await store.get(ompSessionId, ctx.cwd);
109
+ for (const agent of ["codex", "claude"] as const) {
110
+ const id = mapping?.workers[agent]?.sessionId;
111
+ if (id) sessionIds.set(mappingKey(agent, ctx.cwd), id);
112
+ }
113
+ }
114
+
115
+ if (loaded.config.debug) {
116
+ pi.logger.info?.(
117
+ `[multi-harness] config sources: ${loaded.sources.length > 0 ? loaded.sources.join(", ") : "defaults only"}`,
118
+ );
119
+ }
120
+ });
121
+
122
+ pi.on("session_shutdown", async () => {
123
+ // Drain before clearing caches: no child or grandchild may outlive OMP (_spec/08).
124
+ await registry.shutdown().catch((e) => pi.logger.warn?.(`[multi-harness] drain: ${(e as Error).message}`));
125
+ clearAvailabilityCache();
126
+ sessionIds.clear();
127
+ ompSessionId = undefined;
128
+ });
129
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Executable resolution without a shell.
3
+ *
4
+ * Deliberately does not call `which`: no shell, no PATH re-parsing by a child process, and
5
+ * it works identically in tests where PATH is stubbed. See _spec/05-process-runner.md.
6
+ */
7
+ import { accessSync, constants, statSync } from "node:fs";
8
+ import { delimiter, isAbsolute, resolve, sep } from "node:path";
9
+
10
+ const IS_WINDOWS = process.platform === "win32";
11
+
12
+ function isExecutableFile(path: string): boolean {
13
+ try {
14
+ if (!statSync(path).isFile()) return false;
15
+ accessSync(path, constants.X_OK);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function candidateNames(name: string): string[] {
23
+ if (!IS_WINDOWS) return [name];
24
+ const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
25
+ return name.includes(".") ? [name, ...exts.map((e) => name + e)] : exts.map((e) => name + e).concat(name);
26
+ }
27
+
28
+ /**
29
+ * Resolve an executable name or path to an absolute path, or null.
30
+ * A value containing a path separator is treated as a path, not a PATH lookup.
31
+ */
32
+ export function resolveExecutable(nameOrPath: string, env: NodeJS.ProcessEnv = process.env): string | null {
33
+ if (!nameOrPath) return null;
34
+
35
+ if (nameOrPath.includes(sep) || nameOrPath.includes("/") || isAbsolute(nameOrPath)) {
36
+ const abs = resolve(nameOrPath);
37
+ return isExecutableFile(abs) ? abs : null;
38
+ }
39
+
40
+ const pathValue = env.PATH ?? env.Path ?? "";
41
+ for (const dir of pathValue.split(delimiter)) {
42
+ if (!dir) continue;
43
+ for (const candidate of candidateNames(nameOrPath)) {
44
+ const full = resolve(dir, candidate);
45
+ if (isExecutableFile(full)) return full;
46
+ }
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Streaming JSON-lines reader.
3
+ *
4
+ * Both CLIs emit line-delimited JSON on stdout, and a chunk boundary can fall anywhere —
5
+ * including mid-line, or mid-character for a multi-byte UTF-8 code point. This carries the
6
+ * partial line (and, when fed raw bytes, partial code point) across chunks and never
7
+ * throws: a line that does not parse is counted and handed back as raw text for debug
8
+ * logging, rather than being silently swallowed.
9
+ */
10
+ export interface JsonlStats {
11
+ lines: number;
12
+ parsed: number;
13
+ parseErrors: number;
14
+ /** Subset of parseErrors: a line dropped for exceeding MAX_LINE_CHARS. */
15
+ oversizeLines: number;
16
+ }
17
+
18
+ /**
19
+ * Cap on a single line, in UTF-16 code units (a good proxy for bytes on the ASCII-heavy
20
+ * JSON these CLIs emit). A real event line from either CLI is a few KB at most; 1 MiB is
21
+ * generous headroom while still bounding memory against a line that never ends — a hung
22
+ * write, a binary blob accidentally sent to stdout, or a hostile/buggy worker. Chosen to
23
+ * match spawnAgent's own `maxBufferBytes` default (src/process/spawn-agent.ts) so the two
24
+ * caps reason about the same order of magnitude.
25
+ */
26
+ export const MAX_LINE_CHARS = 1_048_576;
27
+
28
+ export class JsonlReader {
29
+ #partial = "";
30
+ /** Only allocated when bytes are pushed; keeps decode state across a split code point. */
31
+ #byteDecoder: TextDecoder | undefined;
32
+ readonly stats: JsonlStats = { lines: 0, parsed: 0, parseErrors: 0, oversizeLines: 0 };
33
+
34
+ constructor(private readonly onValue: (value: unknown, raw: string) => void) {}
35
+
36
+ /**
37
+ * Feed one chunk. Accepts either text (the normal path — callers that already own a
38
+ * persistent decoder, e.g. spawnAgent's stdout handler) or raw bytes; bytes are run
39
+ * through a decoder kept alive across calls, so a multi-byte UTF-8 character split
40
+ * across two `push` calls is reassembled correctly instead of corrupted.
41
+ */
42
+ push(chunk: string | Uint8Array): void {
43
+ const text = typeof chunk === "string" ? chunk : this.#decodeBytes(chunk);
44
+ if (text.length === 0) return;
45
+ this.#partial += text;
46
+ let index: number;
47
+ while ((index = this.#partial.indexOf("\n")) >= 0) {
48
+ const line = this.#partial.slice(0, index);
49
+ this.#partial = this.#partial.slice(index + 1);
50
+ this.#consume(line);
51
+ }
52
+ // Memory-exhaustion guard: nothing terminated this line yet, so it is still growing
53
+ // unbounded. Drop it rather than let a runaway stream buffer forever.
54
+ if (this.#partial.length > MAX_LINE_CHARS) this.#dropOversize();
55
+ }
56
+
57
+ #decodeBytes(chunk: Uint8Array): string {
58
+ this.#byteDecoder ??= new TextDecoder("utf-8");
59
+ return this.#byteDecoder.decode(chunk, { stream: true });
60
+ }
61
+
62
+ /** Flush whatever is left after the stream closes (a final line without a newline). */
63
+ end(): void {
64
+ if (this.#byteDecoder) {
65
+ // Flush any trailing partial code point rather than silently dropping it.
66
+ this.#partial += this.#byteDecoder.decode();
67
+ this.#byteDecoder = undefined;
68
+ }
69
+ if (this.#partial.length > 0) {
70
+ this.#consume(this.#partial);
71
+ this.#partial = "";
72
+ }
73
+ }
74
+
75
+ #dropOversize(): void {
76
+ this.stats.lines++;
77
+ this.stats.oversizeLines++;
78
+ this.stats.parseErrors++;
79
+ this.onValue(undefined, `<line exceeded ${MAX_LINE_CHARS} chars, dropped>`);
80
+ this.#partial = "";
81
+ }
82
+
83
+ #consume(rawLine: string): void {
84
+ const line = rawLine.trim();
85
+ if (line.length === 0) return;
86
+ if (line.length > MAX_LINE_CHARS) {
87
+ // A single newline-terminated line arrived already oversized (e.g. one huge
88
+ // chunk containing its own trailing "\n") — the loop above only guards the
89
+ // still-buffering case, so this line needs its own check.
90
+ this.stats.lines++;
91
+ this.stats.oversizeLines++;
92
+ this.stats.parseErrors++;
93
+ this.onValue(undefined, `<line exceeded ${MAX_LINE_CHARS} chars, dropped>`);
94
+ return;
95
+ }
96
+ this.stats.lines++;
97
+ if (line[0] !== "{" && line[0] !== "[") {
98
+ // Plain text interleaved with the event stream (progress banners, warnings,
99
+ // ANSI-decorated spinners).
100
+ this.stats.parseErrors++;
101
+ this.onValue(undefined, line);
102
+ return;
103
+ }
104
+ try {
105
+ const value: unknown = JSON.parse(line);
106
+ this.stats.parsed++;
107
+ this.onValue(value, line);
108
+ } catch {
109
+ this.stats.parseErrors++;
110
+ this.onValue(undefined, line);
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Convenience for tests and non-streaming callers. */
116
+ export function parseJsonl(text: string): { values: unknown[]; stats: JsonlStats } {
117
+ const values: unknown[] = [];
118
+ const reader = new JsonlReader((value) => {
119
+ if (value !== undefined) values.push(value);
120
+ });
121
+ reader.push(text);
122
+ reader.end();
123
+ return { values, stats: reader.stats };
124
+ }