@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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/index.ts +1 -1
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -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
|
+
}
|