@os-eco/overstory-cli 0.8.0 → 0.8.2

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.
@@ -0,0 +1,698 @@
1
+ // Sapling runtime adapter for overstory's AgentRuntime interface.
2
+ // Implements the AgentRuntime contract for the `sp` CLI (Sapling headless coding agent).
3
+ //
4
+ // Key characteristics:
5
+ // - Headless: Sapling runs as a Bun subprocess (no tmux TUI)
6
+ // - Instruction file: SAPLING.md (auto-read from worktree root)
7
+ // - Communication: NDJSON event stream on stdout (--json)
8
+ // - Guards: .sapling/guards.json (written by deployConfig from guard-rules.ts constants)
9
+ // - Events: NDJSON stream on stdout (parsed for token usage and agent events)
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import {
14
+ DANGEROUS_BASH_PATTERNS,
15
+ INTERACTIVE_TOOLS,
16
+ NATIVE_TEAM_TOOLS,
17
+ SAFE_BASH_PREFIXES,
18
+ WRITE_TOOLS,
19
+ } from "../agents/guard-rules.ts";
20
+ import { DEFAULT_QUALITY_GATES } from "../config.ts";
21
+ import type { ResolvedModel } from "../types.ts";
22
+ import type {
23
+ AgentEvent,
24
+ AgentRuntime,
25
+ ConnectionState,
26
+ DirectSpawnOpts,
27
+ HooksDef,
28
+ OverlayContent,
29
+ ReadyState,
30
+ RpcProcessHandle,
31
+ RuntimeConnection,
32
+ SpawnOpts,
33
+ TranscriptSummary,
34
+ } from "./types.ts";
35
+
36
+ /**
37
+ * Fallback map for bare model aliases when no ANTHROPIC_DEFAULT_*_MODEL env var is set.
38
+ * Used by buildDirectSpawn() to resolve short names to concrete model IDs.
39
+ */
40
+ const SAPLING_ALIAS_FALLBACKS: Record<string, string> = {
41
+ haiku: "claude-haiku-4-5-20251001",
42
+ sonnet: "claude-sonnet-4-6-20251015",
43
+ opus: "claude-opus-4-6-20251015",
44
+ };
45
+
46
+ /**
47
+ * Bash patterns that modify files and require path boundary validation
48
+ * for implementation agents (builder/merger). Mirrors the constant in pi-guards.ts.
49
+ */
50
+ const FILE_MODIFYING_BASH_PATTERNS = [
51
+ "sed\\s+-i",
52
+ "sed\\s+--in-place",
53
+ "echo\\s+.*>",
54
+ "printf\\s+.*>",
55
+ "cat\\s+.*>",
56
+ "tee\\s",
57
+ "\\bmv\\s",
58
+ "\\bcp\\s",
59
+ "\\brm\\s",
60
+ "\\bmkdir\\s",
61
+ "\\btouch\\s",
62
+ "\\bchmod\\s",
63
+ "\\bchown\\s",
64
+ ">>",
65
+ "\\binstall\\s",
66
+ "\\brsync\\s",
67
+ ];
68
+
69
+ /** Capabilities that must not modify project files (read-only mode). */
70
+ const NON_IMPLEMENTATION_CAPABILITIES = new Set([
71
+ "scout",
72
+ "reviewer",
73
+ "lead",
74
+ "coordinator",
75
+ "supervisor",
76
+ "monitor",
77
+ ]);
78
+
79
+ /** Coordination capabilities that get git add/commit whitelisted for metadata sync. */
80
+ const COORDINATION_CAPABILITIES = new Set(["coordinator", "supervisor", "monitor"]);
81
+
82
+ /**
83
+ * Build the full guards configuration object for .sapling/guards.json.
84
+ *
85
+ * Translates overstory guard-rules.ts constants and HooksDef fields into a
86
+ * JSON-serializable format that the `sp` CLI can consume to enforce:
87
+ * - Path boundary: all writes must target files within worktreePath.
88
+ * - Blocked tools: NATIVE_TEAM_TOOLS and INTERACTIVE_TOOLS for all agents;
89
+ * WRITE_TOOLS additionally for non-implementation capabilities.
90
+ * - Bash guards: DANGEROUS_BASH_PATTERNS blocklist (non-impl) or
91
+ * FILE_MODIFYING_BASH_PATTERNS path boundary (impl), with SAFE_BASH_PREFIXES.
92
+ * - Quality gates: commands agents must pass before reporting completion.
93
+ * - Event config: argv arrays for activity tracking via `ov log`.
94
+ *
95
+ * @param hooks - Agent identity, capability, worktree path, and optional quality gates.
96
+ * @returns JSON-serializable guards configuration object.
97
+ */
98
+ function buildGuardsConfig(hooks: HooksDef): Record<string, unknown> {
99
+ const { agentName, capability, worktreePath, qualityGates } = hooks;
100
+ const gates = qualityGates ?? DEFAULT_QUALITY_GATES;
101
+ const isNonImpl = NON_IMPLEMENTATION_CAPABILITIES.has(capability);
102
+ const isCoordination = COORDINATION_CAPABILITIES.has(capability);
103
+
104
+ // Build safe Bash prefixes: base set + coordination extras + quality gate commands.
105
+ const safePrefixes: string[] = [
106
+ ...SAFE_BASH_PREFIXES,
107
+ ...(isCoordination ? ["git add", "git commit"] : []),
108
+ ...gates.map((g) => g.command),
109
+ ];
110
+
111
+ return {
112
+ // Schema version for forward-compatibility.
113
+ version: 1,
114
+ // Agent identity (injected into event tracking commands).
115
+ agentName,
116
+ capability,
117
+ // Path boundary: all file writes must target paths within this directory.
118
+ // Equivalent to the worktree's exclusive file scope.
119
+ pathBoundary: worktreePath,
120
+ // Read-only mode: true for non-implementation capabilities (scout, reviewer, lead, etc.).
121
+ // When true, write tools are blocked in addition to the always-blocked tool set.
122
+ readOnly: isNonImpl,
123
+ // Tool names blocked for ALL agents.
124
+ // - nativeTeamTools: use `ov sling` for delegation instead.
125
+ // - interactiveTools: escalate via `ov mail --type question` instead.
126
+ blockedTools: [...NATIVE_TEAM_TOOLS, ...INTERACTIVE_TOOLS],
127
+ // Tool names blocked only for read-only (non-implementation) agents.
128
+ // Empty array for implementation agents (builder/merger).
129
+ writeToolsBlocked: isNonImpl ? [...WRITE_TOOLS] : [],
130
+ // Write/edit tool names subject to path boundary enforcement (all agents).
131
+ writeToolNames: [...WRITE_TOOLS],
132
+ bashGuards: {
133
+ // Safe Bash prefixes: bypass dangerous pattern checks when matched.
134
+ // Includes base overstory commands, optional git add/commit for coordination,
135
+ // and quality gate command prefixes.
136
+ safePrefixes,
137
+ // Dangerous Bash patterns: blocked for non-implementation agents.
138
+ // Each string is a regex fragment (grep -qE compatible).
139
+ dangerousPatterns: DANGEROUS_BASH_PATTERNS,
140
+ // File-modifying Bash patterns: require path boundary check for implementation agents.
141
+ // Each string is a regex fragment; matched paths must fall within pathBoundary.
142
+ fileModifyingPatterns: FILE_MODIFYING_BASH_PATTERNS,
143
+ },
144
+ // Quality gate commands that must pass before the agent reports task completion.
145
+ qualityGates: gates.map((g) => ({
146
+ name: g.name,
147
+ command: g.command,
148
+ description: g.description,
149
+ })),
150
+ // Activity tracking event configuration.
151
+ // Each value is an argv array passed to Bun.spawn() — no shell interpolation.
152
+ // The `sp` runtime fires these on the corresponding lifecycle events.
153
+ eventConfig: {
154
+ // Fires before each tool executes (updates lastActivity in SessionStore).
155
+ onToolStart: ["ov", "log", "tool-start", "--agent", agentName],
156
+ // Fires after each tool completes.
157
+ onToolEnd: ["ov", "log", "tool-end", "--agent", agentName],
158
+ // Fires when the agent's work loop completes or the process exits.
159
+ onSessionEnd: ["ov", "log", "session-end", "--agent", agentName],
160
+ },
161
+ };
162
+ }
163
+
164
+ /** Pending JSON-RPC getState request waiting for a response. */
165
+ interface PendingRequest {
166
+ resolve: (state: ConnectionState) => void;
167
+ reject: (err: Error) => void;
168
+ timer: ReturnType<typeof setTimeout>;
169
+ }
170
+
171
+ /**
172
+ * RPC connection to a running Sapling agent process.
173
+ *
174
+ * Communicates over stdin/stdout using a simple NDJSON protocol:
175
+ * - Fire-and-forget control messages (steer, followUp, abort) written as plain NDJSON.
176
+ * - getState() uses JSON-RPC 2.0 (id + method) with a background reader routing responses.
177
+ *
178
+ * Background drainStdout() loop reads stdout and routes JSON-RPC 2.0 responses
179
+ * (lines with `jsonrpc` field and numeric `id`) to pending getState() waiters.
180
+ * All other NDJSON events are silently discarded.
181
+ *
182
+ * Not exported — constructed only by SaplingRuntime.connect().
183
+ */
184
+ class SaplingConnection implements RuntimeConnection {
185
+ private nextId = 0;
186
+ private readonly pending = new Map<number, PendingRequest>();
187
+ private closed = false;
188
+ private readonly proc: RpcProcessHandle;
189
+ private readonly timeoutMs: number;
190
+
191
+ constructor(proc: RpcProcessHandle, timeoutMs = 5000) {
192
+ this.proc = proc;
193
+ this.timeoutMs = timeoutMs;
194
+ this.drainStdout();
195
+ }
196
+
197
+ /**
198
+ * Background reader: consumes stdout, routes JSON-RPC responses to pending waiters.
199
+ * Follows the same buffer/split pattern as parseEvents().
200
+ * On stream end or error, rejects all pending requests.
201
+ */
202
+ private drainStdout(): void {
203
+ const reader = this.proc.stdout.getReader();
204
+ const decoder = new TextDecoder();
205
+ let buffer = "";
206
+
207
+ const processLine = (line: string): void => {
208
+ const trimmed = line.trim();
209
+ if (!trimmed) return;
210
+
211
+ let parsed: Record<string, unknown>;
212
+ try {
213
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
214
+ } catch {
215
+ // Skip malformed lines — partial writes or non-JSON debug output
216
+ return;
217
+ }
218
+
219
+ // Route JSON-RPC 2.0 responses: must have jsonrpc field and numeric id
220
+ if (parsed.jsonrpc !== undefined && typeof parsed.id === "number") {
221
+ const pending = this.pending.get(parsed.id);
222
+ if (pending) {
223
+ clearTimeout(pending.timer);
224
+ this.pending.delete(parsed.id);
225
+ pending.resolve(parsed.result as ConnectionState);
226
+ }
227
+ }
228
+ // Non-RPC NDJSON lines are silently discarded
229
+ };
230
+
231
+ const read = async (): Promise<void> => {
232
+ try {
233
+ while (true) {
234
+ const { done, value } = await reader.read();
235
+ if (done) break;
236
+
237
+ buffer += decoder.decode(value, { stream: true });
238
+ const lines = buffer.split("\n");
239
+ buffer = lines.pop() ?? "";
240
+
241
+ for (const line of lines) {
242
+ processLine(line);
243
+ }
244
+ }
245
+
246
+ // Flush remaining buffer on clean stream end
247
+ if (buffer.trim()) {
248
+ processLine(buffer);
249
+ }
250
+ } catch {
251
+ // Stream error — fall through to reject all pending
252
+ } finally {
253
+ reader.releaseLock();
254
+ // Reject all pending on stream end or error
255
+ for (const [, pending] of this.pending) {
256
+ clearTimeout(pending.timer);
257
+ pending.reject(new Error("connection closed"));
258
+ }
259
+ this.pending.clear();
260
+ }
261
+ };
262
+
263
+ // Fire-and-forget background reader
264
+ read().catch(() => {
265
+ // Errors are handled in the finally block above
266
+ });
267
+ }
268
+
269
+ /** Write a JSON message + newline to stdin. */
270
+ private writeMsg(msg: Record<string, unknown>): void {
271
+ const line = `${JSON.stringify(msg)}\n`;
272
+ const result = this.proc.stdin.write(line);
273
+ if (result instanceof Promise) {
274
+ result.catch(() => {
275
+ // Fire-and-forget write errors are non-fatal for control messages
276
+ });
277
+ }
278
+ }
279
+
280
+ async sendPrompt(text: string): Promise<void> {
281
+ this.writeMsg({ method: "steer", params: { content: text } });
282
+ }
283
+
284
+ async followUp(text: string): Promise<void> {
285
+ this.writeMsg({ method: "followUp", params: { content: text } });
286
+ }
287
+
288
+ async abort(): Promise<void> {
289
+ this.writeMsg({ method: "abort" });
290
+ }
291
+
292
+ getState(): Promise<ConnectionState> {
293
+ if (this.closed) {
294
+ return Promise.reject(new Error("connection closed"));
295
+ }
296
+ const id = this.nextId++;
297
+ return new Promise<ConnectionState>((resolve, reject) => {
298
+ const timer = setTimeout(() => {
299
+ this.pending.delete(id);
300
+ reject(new Error("getState timed out"));
301
+ }, this.timeoutMs);
302
+
303
+ this.pending.set(id, { resolve, reject, timer });
304
+
305
+ // Send the request — on write failure, clean up the pending entry
306
+ const line = `${JSON.stringify({ id, method: "getState" })}\n`;
307
+ const result = this.proc.stdin.write(line);
308
+ if (result instanceof Promise) {
309
+ result.catch(() => {
310
+ clearTimeout(timer);
311
+ this.pending.delete(id);
312
+ reject(new Error("write failed"));
313
+ });
314
+ }
315
+ });
316
+ }
317
+
318
+ close(): void {
319
+ this.closed = true;
320
+ for (const [, pending] of this.pending) {
321
+ clearTimeout(pending.timer);
322
+ pending.reject(new Error("connection closed"));
323
+ }
324
+ this.pending.clear();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Sapling runtime adapter.
330
+ *
331
+ * Implements AgentRuntime for the `sp` CLI (Sapling headless coding agent).
332
+ * Sapling workers run as headless Bun subprocesses — they communicate via
333
+ * JSON-RPC on stdin/stdout rather than a TUI in a tmux pane. This means
334
+ * all tmux lifecycle methods (buildSpawnCommand, detectReady, requiresBeaconVerification)
335
+ * are stubs: the orchestrator checks `runtime.headless === true` and takes the
336
+ * direct-spawn code path instead.
337
+ *
338
+ * Instructions are delivered via `SAPLING.md` in the worktree root.
339
+ * Guard configuration is written to `.sapling/guards.json` (stub for Wave 3).
340
+ *
341
+ * Hardware impact: Sapling workers use 60–120 MB RAM vs 250–400 MB for TUI agents,
342
+ * enabling 4–6× more concurrent workers on a typical developer machine.
343
+ */
344
+ export class SaplingRuntime implements AgentRuntime {
345
+ /** Unique identifier for this runtime. */
346
+ readonly id = "sapling";
347
+
348
+ /** Relative path to the instruction file within a worktree. */
349
+ readonly instructionPath = "SAPLING.md";
350
+
351
+ /**
352
+ * Whether this runtime is headless (no tmux, direct subprocess).
353
+ * Headless runtimes bypass all tmux session management and use Bun.spawn directly.
354
+ */
355
+ readonly headless = true;
356
+
357
+ /**
358
+ * Build the shell command string to spawn a Sapling agent in a tmux pane.
359
+ *
360
+ * This method exists for the TUI fallback path (e.g., `ov sling --runtime sapling`
361
+ * on a host that has tmux). Under normal operation, Sapling is headless and
362
+ * buildDirectSpawn() is used instead.
363
+ *
364
+ * Maps SpawnOpts to `sp run` flags:
365
+ * - `model` → `--model <model>`
366
+ * - `appendSystemPromptFile` → prepended via `$(cat ...)` shell expansion
367
+ * - `appendSystemPrompt` → appended inline
368
+ * - `permissionMode` is accepted but NOT mapped — Sapling enforces security
369
+ * via .sapling/guards.json rather than permission flags.
370
+ *
371
+ * @param opts - Spawn options (model, appendSystemPrompt; permissionMode ignored)
372
+ * @returns Shell command string suitable for tmux new-session -c
373
+ */
374
+ buildSpawnCommand(opts: SpawnOpts): string {
375
+ let cmd = `sp run --model ${opts.model} --json`;
376
+
377
+ if (opts.appendSystemPromptFile) {
378
+ // Read role definition from file at shell expansion time — avoids tmux
379
+ // IPC message size limits. Append the "read SAPLING.md" instruction.
380
+ const escaped = opts.appendSystemPromptFile.replace(/'/g, "'\\''");
381
+ cmd += ` "$(cat '${escaped}')"' Read SAPLING.md for your task assignment and begin immediately.'`;
382
+ } else if (opts.appendSystemPrompt) {
383
+ // Inline role definition + instruction to read SAPLING.md.
384
+ const prompt = `${opts.appendSystemPrompt}\n\nRead SAPLING.md for your task assignment and begin immediately.`;
385
+ const escaped = prompt.replace(/'/g, "'\\''");
386
+ cmd += ` '${escaped}'`;
387
+ } else {
388
+ cmd += ` 'Read SAPLING.md for your task assignment and begin immediately.'`;
389
+ }
390
+
391
+ return cmd;
392
+ }
393
+
394
+ /**
395
+ * Build the argv array for a headless one-shot Sapling invocation.
396
+ *
397
+ * Returns an argv array suitable for `Bun.spawn()`. The `sp print` subcommand
398
+ * processes a prompt and exits, printing the result to stdout.
399
+ *
400
+ * Used by merge/resolver.ts (AI-assisted conflict resolution) and
401
+ * watchdog/triage.ts (AI-assisted failure classification).
402
+ *
403
+ * @param prompt - The prompt to pass as the argument
404
+ * @param model - Optional model override
405
+ * @returns Argv array for Bun.spawn
406
+ */
407
+ buildPrintCommand(prompt: string, model?: string): string[] {
408
+ const cmd = ["sp", "print"];
409
+ if (model !== undefined) {
410
+ cmd.push("--model", model);
411
+ }
412
+ cmd.push(prompt);
413
+ return cmd;
414
+ }
415
+
416
+ /**
417
+ * Build the argv array for Bun.spawn() to launch a Sapling agent subprocess.
418
+ *
419
+ * Returns an argv array that starts the Sapling agent with NDJSON event output. The agent
420
+ * reads its instructions from the file at `opts.instructionPath`, processes
421
+ * the task, emits NDJSON events on stdout, and exits on completion.
422
+ *
423
+ * @param opts - Direct spawn options (cwd, env, model, instructionPath)
424
+ * @returns Argv array for Bun.spawn — do not shell-interpolate
425
+ */
426
+ buildDirectSpawn(opts: DirectSpawnOpts): string[] {
427
+ // Resolve the actual model name: if this is an alias (e.g. "sonnet") routed
428
+ // through a gateway, the real model ID is in the env vars. Sapling passes
429
+ // --model directly to the SDK, so it needs the actual model ID, not the alias.
430
+ let model = opts.model;
431
+ let resolved = false;
432
+ if (opts.env) {
433
+ const aliasKey = `ANTHROPIC_DEFAULT_${model.toUpperCase()}_MODEL`;
434
+ const envResolved = opts.env[aliasKey];
435
+ if (envResolved) {
436
+ model = envResolved;
437
+ resolved = true;
438
+ }
439
+ }
440
+ // Fallback: bare aliases (haiku/sonnet/opus) with no gateway env var → concrete model ID.
441
+ if (!resolved) {
442
+ const fallback = SAPLING_ALIAS_FALLBACKS[model];
443
+ if (fallback !== undefined) {
444
+ model = fallback;
445
+ }
446
+ }
447
+
448
+ return [
449
+ "sp",
450
+ "run",
451
+ "--model",
452
+ model,
453
+ "--json",
454
+ "--cwd",
455
+ opts.cwd,
456
+ "--system-prompt-file",
457
+ opts.instructionPath,
458
+ "Read SAPLING.md for your task assignment and begin immediately.",
459
+ ];
460
+ }
461
+
462
+ /**
463
+ * Deploy per-agent instructions and guard configuration to a worktree.
464
+ *
465
+ * Writes the overlay content to `SAPLING.md` in the worktree root.
466
+ * Also writes `.sapling/guards.json` with the full guard configuration
467
+ * derived from `hooks` — translating overstory guard-rules.ts constants
468
+ * into JSON-serializable form for the `sp` CLI to enforce.
469
+ *
470
+ * @param worktreePath - Absolute path to the agent's git worktree
471
+ * @param overlay - Overlay content to write as SAPLING.md, or undefined for hooks-only deployment
472
+ * @param hooks - Agent identity, capability, and quality gates for guard config
473
+ */
474
+ async deployConfig(
475
+ worktreePath: string,
476
+ overlay: OverlayContent | undefined,
477
+ hooks: HooksDef,
478
+ ): Promise<void> {
479
+ // Write SAPLING.md instruction file (only when overlay is provided).
480
+ if (overlay) {
481
+ const saplingPath = join(worktreePath, this.instructionPath);
482
+ await mkdir(dirname(saplingPath), { recursive: true });
483
+ await Bun.write(saplingPath, overlay.content);
484
+ }
485
+
486
+ // Always write .sapling/guards.json — even when overlay is undefined
487
+ // (hooks-only deployment for coordinator/supervisor/monitor).
488
+ const guardsPath = join(worktreePath, ".sapling", "guards.json");
489
+ await mkdir(dirname(guardsPath), { recursive: true });
490
+ await Bun.write(guardsPath, `${JSON.stringify(buildGuardsConfig(hooks), null, 2)}\n`);
491
+ }
492
+
493
+ /**
494
+ * Sapling is headless — always ready.
495
+ *
496
+ * Sapling runs as a direct subprocess that emits a `{"type":"ready"}` event
497
+ * on stdout when initialization completes. Tmux-based readiness detection
498
+ * is never used for Sapling workers.
499
+ *
500
+ * @param _paneContent - Captured tmux pane content (unused)
501
+ * @returns Always `{ phase: "ready" }`
502
+ */
503
+ detectReady(_paneContent: string): ReadyState {
504
+ return { phase: "ready" };
505
+ }
506
+
507
+ /**
508
+ * Sapling does not require beacon verification/resend.
509
+ *
510
+ * The beacon verification loop exists because Claude Code's TUI sometimes
511
+ * swallows the initial Enter during late initialization. Sapling is headless —
512
+ * it communicates via stdin/stdout with no TUI startup delay.
513
+ */
514
+ requiresBeaconVerification(): boolean {
515
+ return false;
516
+ }
517
+
518
+ /**
519
+ * Parse a Sapling NDJSON transcript file into normalized token usage.
520
+ *
521
+ * Sapling emits NDJSON events on stdout during execution. The transcript
522
+ * file records these events. Token usage is extracted from events that
523
+ * carry a `usage` object with `input_tokens` and/or `output_tokens` fields.
524
+ * Model identity is extracted from any event that carries a `model` field.
525
+ *
526
+ * Returns null if the file does not exist or cannot be parsed.
527
+ *
528
+ * @param path - Absolute path to the Sapling NDJSON transcript file
529
+ * @returns Aggregated token usage, or null if unavailable
530
+ */
531
+ async parseTranscript(path: string): Promise<TranscriptSummary | null> {
532
+ const file = Bun.file(path);
533
+ if (!(await file.exists())) {
534
+ return null;
535
+ }
536
+
537
+ try {
538
+ const text = await file.text();
539
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
540
+
541
+ let inputTokens = 0;
542
+ let outputTokens = 0;
543
+ let model = "";
544
+
545
+ for (const line of lines) {
546
+ let event: Record<string, unknown>;
547
+ try {
548
+ event = JSON.parse(line) as Record<string, unknown>;
549
+ } catch {
550
+ // Skip malformed lines — partial writes during capture.
551
+ continue;
552
+ }
553
+
554
+ // Extract token usage from any event carrying a usage object.
555
+ if (typeof event.usage === "object" && event.usage !== null) {
556
+ const usage = event.usage as Record<string, unknown>;
557
+ if (typeof usage.input_tokens === "number") {
558
+ inputTokens += usage.input_tokens;
559
+ }
560
+ if (typeof usage.output_tokens === "number") {
561
+ outputTokens += usage.output_tokens;
562
+ }
563
+ }
564
+
565
+ // Capture model from any event that carries it.
566
+ if (typeof event.model === "string" && event.model && !model) {
567
+ model = event.model;
568
+ }
569
+ }
570
+
571
+ return { inputTokens, outputTokens, model };
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Parse NDJSON stdout from a Sapling agent subprocess into typed AgentEvent objects.
579
+ *
580
+ * Reads the ReadableStream from Bun.spawn() stdout, buffers partial lines,
581
+ * and yields a typed AgentEvent for each complete JSON line. Malformed lines
582
+ * (partial writes, non-JSON output) are silently skipped.
583
+ *
584
+ * The NDJSON format mirrors Pi's `--mode json` output so `ov feed`, `ov trace`,
585
+ * and `ov costs` work without runtime-specific parsing.
586
+ *
587
+ * @param stream - ReadableStream<Uint8Array> from Bun.spawn stdout
588
+ * @yields Parsed AgentEvent objects in emission order
589
+ */
590
+ async *parseEvents(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
591
+ const reader = stream.getReader();
592
+ const decoder = new TextDecoder();
593
+ let buffer = "";
594
+
595
+ try {
596
+ while (true) {
597
+ const result = await reader.read();
598
+ if (result.done) break;
599
+
600
+ buffer += decoder.decode(result.value, { stream: true });
601
+
602
+ // Split on newlines, keeping the remainder in the buffer.
603
+ const lines = buffer.split("\n");
604
+ // The last element is either empty or an incomplete line.
605
+ buffer = lines.pop() ?? "";
606
+
607
+ for (const line of lines) {
608
+ const trimmed = line.trim();
609
+ if (!trimmed) continue;
610
+
611
+ try {
612
+ const event = JSON.parse(trimmed) as AgentEvent;
613
+ yield event;
614
+ } catch {
615
+ // Skip malformed lines — partial writes or debug output.
616
+ }
617
+ }
618
+ }
619
+
620
+ // Flush any remaining buffer content after stream ends.
621
+ const remaining = buffer.trim();
622
+ if (remaining) {
623
+ try {
624
+ const event = JSON.parse(remaining) as AgentEvent;
625
+ yield event;
626
+ } catch {
627
+ // Skip malformed trailing line.
628
+ }
629
+ }
630
+ } finally {
631
+ reader.releaseLock();
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Build runtime-specific environment variables for spawning sapling.
637
+ *
638
+ * Translates overstory's gateway provider env vars into what sapling expects.
639
+ * Worktrees don't have .env files (gitignored), so overstory must pass
640
+ * provider credentials — same as it does for every other runtime.
641
+ *
642
+ * Key translations:
643
+ * - ANTHROPIC_AUTH_TOKEN → ANTHROPIC_API_KEY (sapling SDK reads API_KEY)
644
+ * - ANTHROPIC_BASE_URL passed through as-is
645
+ * - SAPLING_BACKEND=sdk forced when gateway provider is configured
646
+ *
647
+ * @param model - Resolved model with optional provider env vars
648
+ * @returns Environment variable map for sapling subprocess
649
+ */
650
+ /**
651
+ * Establish a direct RPC connection to a running Sapling agent process.
652
+ *
653
+ * Returns a SaplingConnection that multiplexes getState() JSON-RPC 2.0
654
+ * requests over stdin/stdout alongside the normal NDJSON event stream.
655
+ *
656
+ * @param process - Stdin/stdout handles from the spawned agent subprocess
657
+ * @returns RuntimeConnection for RPC-based health checks and control
658
+ */
659
+ connect(process: RpcProcessHandle): RuntimeConnection {
660
+ return new SaplingConnection(process);
661
+ }
662
+
663
+ buildEnv(model: ResolvedModel): Record<string, string> {
664
+ const env: Record<string, string> = {
665
+ // Clear Claude Code session markers so sapling doesn't auto-detect
666
+ // SDK backend when spawned from a Claude Code session (CLAUDECODE=1).
667
+ CLAUDECODE: "",
668
+ CLAUDE_CODE_SSE_PORT: "",
669
+ CLAUDE_CODE_ENTRYPOINT: "",
670
+ };
671
+
672
+ const providerEnv = model.env ?? {};
673
+
674
+ // Gateway providers use ANTHROPIC_AUTH_TOKEN; sapling's SDK reads ANTHROPIC_API_KEY.
675
+ if (providerEnv.ANTHROPIC_AUTH_TOKEN) {
676
+ env.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN;
677
+ }
678
+ if (providerEnv.ANTHROPIC_BASE_URL) {
679
+ env.ANTHROPIC_BASE_URL = providerEnv.ANTHROPIC_BASE_URL;
680
+ }
681
+ // Force SDK backend when a gateway provider is configured.
682
+ if (providerEnv.ANTHROPIC_AUTH_TOKEN || providerEnv.ANTHROPIC_BASE_URL) {
683
+ env.SAPLING_BACKEND = "sdk";
684
+ }
685
+
686
+ // Forward model alias env vars so buildDirectSpawn can resolve gateway-routed models.
687
+ // resolveProviderEnv sets ANTHROPIC_DEFAULT_<ALIAS>_MODEL (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL)
688
+ // to point to the real model ID behind the gateway. Without forwarding these,
689
+ // buildDirectSpawn cannot find the real model ID and falls back to the bare alias.
690
+ for (const [key, value] of Object.entries(providerEnv)) {
691
+ if (key.startsWith("ANTHROPIC_DEFAULT_") && key.endsWith("_MODEL")) {
692
+ env[key] = value;
693
+ }
694
+ }
695
+
696
+ return env;
697
+ }
698
+ }