@os-eco/overstory-cli 0.9.4 → 0.10.3
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 +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/runtimes/claude.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { estimateCost } from "../metrics/pricing.ts";
|
|
|
9
9
|
import { parseTranscriptUsage } from "../metrics/transcript.ts";
|
|
10
10
|
import type { ResolvedModel } from "../types.ts";
|
|
11
11
|
import type {
|
|
12
|
+
AgentEvent,
|
|
12
13
|
AgentRuntime,
|
|
14
|
+
DirectSpawnOpts,
|
|
13
15
|
HooksDef,
|
|
14
16
|
OverlayContent,
|
|
15
17
|
ReadyState,
|
|
@@ -126,7 +128,22 @@ export class ClaudeRuntime implements AgentRuntime {
|
|
|
126
128
|
await Bun.write(claudeMdPath, overlay.content);
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
|
|
131
|
+
// Always deploy hooks — headless Claude Code DOES dispatch settings.local.json
|
|
132
|
+
// PreToolUse hooks (verified empirically against `claude -p --output-format stream-json`).
|
|
133
|
+
// The original design (overstory-1c32 / docs/headless-hooks-design.md Q6) wrongly assumed
|
|
134
|
+
// hooks don't fire in headless mode and skipped deployment, leaving headless agents with
|
|
135
|
+
// none of the destructive-command guards. overstory-e24b reverses that: in headless mode
|
|
136
|
+
// we deploy a settings.local.json containing only PreToolUse security guards (path boundary,
|
|
137
|
+
// capability blocks, bash danger patterns, tracker close, lead close gate). The other
|
|
138
|
+
// hook types are dropped because they have headless equivalents already wired up
|
|
139
|
+
// (initial stdin prompt, serve mail injection loop, stream-json event capture).
|
|
140
|
+
await deployHooks(
|
|
141
|
+
hooks.worktreePath,
|
|
142
|
+
hooks.agentName,
|
|
143
|
+
hooks.capability,
|
|
144
|
+
hooks.qualityGates,
|
|
145
|
+
hooks.isHeadless ?? false,
|
|
146
|
+
);
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
/**
|
|
@@ -218,6 +235,311 @@ export class ClaudeRuntime implements AgentRuntime {
|
|
|
218
235
|
}
|
|
219
236
|
}
|
|
220
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Build the argv array for Bun.spawn() to launch a Claude Code agent in headless mode.
|
|
240
|
+
*
|
|
241
|
+
* Returns the exact flags Multica uses for headless Claude Code sessions:
|
|
242
|
+
* `-p --output-format stream-json --input-format stream-json --verbose
|
|
243
|
+
* --strict-mcp-config --permission-mode bypassPermissions [--model <m>]`
|
|
244
|
+
*
|
|
245
|
+
* Claude Code reads `.claude/CLAUDE.md` from cwd automatically — do NOT
|
|
246
|
+
* pass `--append-system-prompt` or consume `opts.instructionPath`.
|
|
247
|
+
* The initial stdin prompt is the caller's responsibility.
|
|
248
|
+
*
|
|
249
|
+
* @param opts - Direct spawn options; only `model` is consumed
|
|
250
|
+
* @returns Argv array for Bun.spawn — do not shell-interpolate
|
|
251
|
+
*/
|
|
252
|
+
buildDirectSpawn(opts: DirectSpawnOpts): string[] {
|
|
253
|
+
const argv = [
|
|
254
|
+
"claude",
|
|
255
|
+
"-p",
|
|
256
|
+
"--output-format",
|
|
257
|
+
"stream-json",
|
|
258
|
+
"--input-format",
|
|
259
|
+
"stream-json",
|
|
260
|
+
"--verbose",
|
|
261
|
+
"--strict-mcp-config",
|
|
262
|
+
"--permission-mode",
|
|
263
|
+
"bypassPermissions",
|
|
264
|
+
];
|
|
265
|
+
if (opts.model !== undefined) {
|
|
266
|
+
argv.push("--model", opts.model);
|
|
267
|
+
}
|
|
268
|
+
// Phase 1 (overstory-b835): emit --resume on follow-up spawns. Mirrors
|
|
269
|
+
// multica/server/pkg/agent/claude.go:434 (positional). Empty string and
|
|
270
|
+
// null are treated as "no resume" — only non-empty strings activate it.
|
|
271
|
+
if (typeof opts.resumeSessionId === "string" && opts.resumeSessionId.length > 0) {
|
|
272
|
+
argv.push("--resume", opts.resumeSessionId);
|
|
273
|
+
}
|
|
274
|
+
return argv;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parse stream-json stdout from a Claude Code headless subprocess into typed AgentEvent objects.
|
|
279
|
+
*
|
|
280
|
+
* Reads the ReadableStream from Bun.spawn() stdout, buffers partial lines,
|
|
281
|
+
* and yields a typed AgentEvent for each complete JSON line. Malformed lines
|
|
282
|
+
* and unknown message types are silently skipped.
|
|
283
|
+
*
|
|
284
|
+
* Adjacent assistant_text deltas are coalesced into a single assistant_message
|
|
285
|
+
* event using a batching window. The batch flushes on timer expiry, size cap,
|
|
286
|
+
* any non-text event, or stream end — whichever comes first.
|
|
287
|
+
*
|
|
288
|
+
* Event mapping (Claude stream-json → AgentEvent):
|
|
289
|
+
* - assistant/text → buffered; emitted as one assistant_message per batch
|
|
290
|
+
* - assistant/tool_use → { type: "tool_use", callId, name, input }
|
|
291
|
+
* - assistant/thinking → (skipped)
|
|
292
|
+
* - user/tool_result → { type: "tool_result", toolUseId, content }
|
|
293
|
+
* - system → { type: "status", sessionId, subtype }
|
|
294
|
+
* - result → { type: "result", sessionId, result, isError, durationMs, numTurns }
|
|
295
|
+
*
|
|
296
|
+
* @param stream - ReadableStream<Uint8Array> from Bun.spawn stdout
|
|
297
|
+
* @param opts - Optional hooks and tuning:
|
|
298
|
+
* - `onSessionId` is invoked once, synchronously, on the first event that carries a
|
|
299
|
+
* non-empty `sessionId`. Consumer errors are swallowed so they cannot crash the parser.
|
|
300
|
+
* - `flushIntervalMs` — Max ms to buffer text before emitting (default 500).
|
|
301
|
+
* - `flushSizeBytes` — Max UTF-8 byte size of a text batch (default 4096).
|
|
302
|
+
* @yields Parsed AgentEvent objects in emission order
|
|
303
|
+
*/
|
|
304
|
+
async *parseEvents(
|
|
305
|
+
stream: ReadableStream<Uint8Array>,
|
|
306
|
+
opts?: {
|
|
307
|
+
onSessionId?: (sessionId: string) => void;
|
|
308
|
+
flushIntervalMs?: number;
|
|
309
|
+
flushSizeBytes?: number;
|
|
310
|
+
},
|
|
311
|
+
): AsyncIterable<AgentEvent> {
|
|
312
|
+
const flushIntervalMs = opts?.flushIntervalMs ?? 500;
|
|
313
|
+
const flushSizeBytes = opts?.flushSizeBytes ?? 4096;
|
|
314
|
+
|
|
315
|
+
const reader = stream.getReader();
|
|
316
|
+
const decoder = new TextDecoder();
|
|
317
|
+
let buffer = "";
|
|
318
|
+
let sessionIdPinned = false;
|
|
319
|
+
|
|
320
|
+
// Batch state for adjacent assistant_text deltas.
|
|
321
|
+
let pendingText: string[] = [];
|
|
322
|
+
let pendingByteSize = 0;
|
|
323
|
+
let pendingStartTs: string | null = null;
|
|
324
|
+
let pendingModel: string | undefined;
|
|
325
|
+
let pendingUsage: unknown;
|
|
326
|
+
|
|
327
|
+
// Returns batched assistant_message event and resets state; null when buffer is empty.
|
|
328
|
+
const flushText = (): AgentEvent | null => {
|
|
329
|
+
if (pendingText.length === 0) return null;
|
|
330
|
+
const text = pendingText.join("");
|
|
331
|
+
const event: AgentEvent = {
|
|
332
|
+
type: "assistant_message",
|
|
333
|
+
timestamp: pendingStartTs ?? new Date().toISOString(),
|
|
334
|
+
text,
|
|
335
|
+
};
|
|
336
|
+
if (pendingModel !== undefined) event.model = pendingModel;
|
|
337
|
+
if (pendingUsage !== undefined) event.usage = pendingUsage;
|
|
338
|
+
pendingText = [];
|
|
339
|
+
pendingByteSize = 0;
|
|
340
|
+
pendingStartTs = null;
|
|
341
|
+
pendingModel = undefined;
|
|
342
|
+
pendingUsage = undefined;
|
|
343
|
+
return event;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Sync generator: parses one JSON line, yielding AgentEvents in order.
|
|
347
|
+
// Mutates batch state via closure — do not call concurrently.
|
|
348
|
+
function* processLine(line: string): Generator<AgentEvent> {
|
|
349
|
+
const trimmed = line.trim();
|
|
350
|
+
if (!trimmed) return;
|
|
351
|
+
|
|
352
|
+
let msg: Record<string, unknown>;
|
|
353
|
+
try {
|
|
354
|
+
msg = JSON.parse(trimmed) as Record<string, unknown>;
|
|
355
|
+
} catch {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const timestamp = new Date().toISOString();
|
|
360
|
+
|
|
361
|
+
if (msg.type === "assistant") {
|
|
362
|
+
const message =
|
|
363
|
+
typeof msg.message === "object" && msg.message !== null
|
|
364
|
+
? (msg.message as Record<string, unknown>)
|
|
365
|
+
: null;
|
|
366
|
+
if (!message) return;
|
|
367
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
368
|
+
const model = typeof message.model === "string" ? message.model : undefined;
|
|
369
|
+
const usage = message.usage !== undefined ? message.usage : undefined;
|
|
370
|
+
|
|
371
|
+
for (const block of content) {
|
|
372
|
+
if (typeof block !== "object" || block === null) continue;
|
|
373
|
+
const b = block as Record<string, unknown>;
|
|
374
|
+
if (b.type === "text") {
|
|
375
|
+
const text = typeof b.text === "string" ? b.text : String(b.text);
|
|
376
|
+
const textByteSize = new TextEncoder().encode(text).byteLength;
|
|
377
|
+
|
|
378
|
+
// Size-cap: if appending would exceed cap and buffer is non-empty, flush first.
|
|
379
|
+
if (pendingByteSize > 0 && pendingByteSize + textByteSize > flushSizeBytes) {
|
|
380
|
+
const ev = flushText();
|
|
381
|
+
if (ev) yield ev;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Record the start-of-batch timestamp on the first fragment.
|
|
385
|
+
if (pendingByteSize === 0) pendingStartTs = timestamp;
|
|
386
|
+
|
|
387
|
+
pendingText.push(text);
|
|
388
|
+
pendingByteSize += textByteSize;
|
|
389
|
+
// Latest contributing message wins for model/usage.
|
|
390
|
+
if (model !== undefined) pendingModel = model;
|
|
391
|
+
if (usage !== undefined) pendingUsage = usage;
|
|
392
|
+
|
|
393
|
+
// Immediate flush when a single fragment meets or exceeds the size cap.
|
|
394
|
+
if (pendingByteSize >= flushSizeBytes) {
|
|
395
|
+
const ev = flushText();
|
|
396
|
+
if (ev) yield ev;
|
|
397
|
+
}
|
|
398
|
+
} else if (b.type === "tool_use") {
|
|
399
|
+
// Non-text block: flush pending text first to preserve in-order delivery.
|
|
400
|
+
const ev = flushText();
|
|
401
|
+
if (ev) yield ev;
|
|
402
|
+
yield {
|
|
403
|
+
type: "tool_use",
|
|
404
|
+
timestamp,
|
|
405
|
+
callId: b.id,
|
|
406
|
+
name: b.name,
|
|
407
|
+
input: b.input,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// thinking and other block types → skip
|
|
411
|
+
}
|
|
412
|
+
} else if (msg.type === "user") {
|
|
413
|
+
const message =
|
|
414
|
+
typeof msg.message === "object" && msg.message !== null
|
|
415
|
+
? (msg.message as Record<string, unknown>)
|
|
416
|
+
: null;
|
|
417
|
+
if (!message) return;
|
|
418
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
419
|
+
|
|
420
|
+
const flushEv = flushText();
|
|
421
|
+
if (flushEv) yield flushEv;
|
|
422
|
+
|
|
423
|
+
for (const block of content) {
|
|
424
|
+
if (typeof block !== "object" || block === null) continue;
|
|
425
|
+
const b = block as Record<string, unknown>;
|
|
426
|
+
if (b.type === "tool_result") {
|
|
427
|
+
yield {
|
|
428
|
+
type: "tool_result",
|
|
429
|
+
timestamp,
|
|
430
|
+
toolUseId: b.tool_use_id,
|
|
431
|
+
content: b.content,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} else if (msg.type === "system") {
|
|
436
|
+
const flushEv = flushText();
|
|
437
|
+
if (flushEv) yield flushEv;
|
|
438
|
+
yield { type: "status", timestamp, sessionId: msg.session_id, subtype: msg.subtype };
|
|
439
|
+
} else if (msg.type === "result") {
|
|
440
|
+
const flushEv = flushText();
|
|
441
|
+
if (flushEv) yield flushEv;
|
|
442
|
+
yield {
|
|
443
|
+
type: "result",
|
|
444
|
+
timestamp,
|
|
445
|
+
sessionId: msg.session_id,
|
|
446
|
+
result: msg.result,
|
|
447
|
+
isError: msg.is_error,
|
|
448
|
+
durationMs: msg.duration_ms,
|
|
449
|
+
numTurns: msg.num_turns,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const maybePinSession = (event: AgentEvent): void => {
|
|
455
|
+
if (sessionIdPinned || !opts?.onSessionId) return;
|
|
456
|
+
const sid = event.sessionId;
|
|
457
|
+
if (typeof sid !== "string" || sid.length === 0) return;
|
|
458
|
+
sessionIdPinned = true;
|
|
459
|
+
try {
|
|
460
|
+
opts.onSessionId(sid);
|
|
461
|
+
} catch {
|
|
462
|
+
// Consumer errors must not crash the parser.
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
// Use the inferred return type of reader.read() to stay compatible with
|
|
468
|
+
// both the standard Web Streams API and Bun's slightly divergent typings.
|
|
469
|
+
type ReadResult = Awaited<ReturnType<typeof reader.read>>;
|
|
470
|
+
type Race = { kind: "read"; result: ReadResult } | { kind: "timeout" };
|
|
471
|
+
|
|
472
|
+
let readPromise = reader.read();
|
|
473
|
+
|
|
474
|
+
while (true) {
|
|
475
|
+
if (pendingText.length > 0 && pendingStartTs !== null) {
|
|
476
|
+
// Race the next read chunk against the flush timer.
|
|
477
|
+
const elapsed = Date.now() - new Date(pendingStartTs).getTime();
|
|
478
|
+
const remaining = Math.max(0, flushIntervalMs - elapsed);
|
|
479
|
+
|
|
480
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
481
|
+
const wrappedRead: Promise<Race> = readPromise.then((result) => ({
|
|
482
|
+
kind: "read" as const,
|
|
483
|
+
result,
|
|
484
|
+
}));
|
|
485
|
+
const wrappedTimer = new Promise<Race>((resolve) => {
|
|
486
|
+
timerId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const winner = await Promise.race([wrappedRead, wrappedTimer]);
|
|
490
|
+
if (timerId !== undefined) clearTimeout(timerId);
|
|
491
|
+
|
|
492
|
+
if (winner.kind === "timeout") {
|
|
493
|
+
const ev = flushText();
|
|
494
|
+
if (ev) yield ev;
|
|
495
|
+
continue; // readPromise still in flight — re-enter loop without refreshing
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const result = winner.result;
|
|
499
|
+
if (result.done) break;
|
|
500
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
501
|
+
const lines = buffer.split("\n");
|
|
502
|
+
buffer = lines.pop() ?? "";
|
|
503
|
+
for (const line of lines) {
|
|
504
|
+
for (const event of processLine(line)) {
|
|
505
|
+
maybePinSession(event);
|
|
506
|
+
yield event;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
readPromise = reader.read();
|
|
510
|
+
} else {
|
|
511
|
+
const result = await readPromise;
|
|
512
|
+
if (result.done) break;
|
|
513
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
514
|
+
const lines = buffer.split("\n");
|
|
515
|
+
// Last element is either empty or an incomplete line — keep in buffer.
|
|
516
|
+
buffer = lines.pop() ?? "";
|
|
517
|
+
for (const line of lines) {
|
|
518
|
+
for (const event of processLine(line)) {
|
|
519
|
+
maybePinSession(event);
|
|
520
|
+
yield event;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
readPromise = reader.read();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Flush remaining buffer on clean stream end (no trailing newline).
|
|
528
|
+
if (buffer.trim()) {
|
|
529
|
+
for (const event of processLine(buffer)) {
|
|
530
|
+
maybePinSession(event);
|
|
531
|
+
yield event;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Drain any remaining buffered text after the read loop exits.
|
|
536
|
+
const finalEv = flushText();
|
|
537
|
+
if (finalEv) yield finalEv;
|
|
538
|
+
} finally {
|
|
539
|
+
reader.releaseLock();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
221
543
|
/**
|
|
222
544
|
* Build runtime-specific environment variables for model/provider routing.
|
|
223
545
|
*
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
addHeadlessConnectionListener,
|
|
4
|
+
getConnection,
|
|
5
|
+
registerHeadlessConnection,
|
|
6
|
+
removeConnection,
|
|
7
|
+
setConnection,
|
|
8
|
+
} from "./connections.ts";
|
|
3
9
|
import type { ConnectionState, RuntimeConnection } from "./types.ts";
|
|
4
10
|
|
|
5
11
|
/** Minimal RuntimeConnection stub for testing the registry. */
|
|
@@ -72,3 +78,137 @@ describe("connection registry", () => {
|
|
|
72
78
|
expect(getConnection("agent-delta")).toBe(conn2);
|
|
73
79
|
});
|
|
74
80
|
});
|
|
81
|
+
|
|
82
|
+
describe("headless connection listener API", () => {
|
|
83
|
+
const usedNames: string[] = [];
|
|
84
|
+
const unsubscribers: Array<() => void> = [];
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
for (const unsub of unsubscribers.splice(0)) unsub();
|
|
88
|
+
for (const name of usedNames.splice(0)) {
|
|
89
|
+
if (getConnection(name)) {
|
|
90
|
+
removeConnection(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function makeStdin(): { write(): Promise<number> } {
|
|
96
|
+
return { write: () => Promise.resolve(0) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("onRegister fires when registerHeadlessConnection runs", () => {
|
|
100
|
+
const seen: Array<{ name: string }> = [];
|
|
101
|
+
unsubscribers.push(
|
|
102
|
+
addHeadlessConnectionListener({
|
|
103
|
+
onRegister(agentName) {
|
|
104
|
+
seen.push({ name: agentName });
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
usedNames.push("listener-agent-1");
|
|
110
|
+
registerHeadlessConnection("listener-agent-1", { pid: 99999, stdin: makeStdin() });
|
|
111
|
+
|
|
112
|
+
expect(seen).toEqual([{ name: "listener-agent-1" }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("onRemove fires when a headless connection is removed", () => {
|
|
116
|
+
const removed: string[] = [];
|
|
117
|
+
unsubscribers.push(
|
|
118
|
+
addHeadlessConnectionListener({
|
|
119
|
+
onRegister: () => {},
|
|
120
|
+
onRemove(agentName) {
|
|
121
|
+
removed.push(agentName);
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
usedNames.push("listener-agent-2");
|
|
127
|
+
registerHeadlessConnection("listener-agent-2", { pid: 99999, stdin: makeStdin() });
|
|
128
|
+
removeConnection("listener-agent-2");
|
|
129
|
+
|
|
130
|
+
expect(removed).toEqual(["listener-agent-2"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("onRemove does NOT fire for non-headless connections", () => {
|
|
134
|
+
const removed: string[] = [];
|
|
135
|
+
unsubscribers.push(
|
|
136
|
+
addHeadlessConnectionListener({
|
|
137
|
+
onRegister: () => {},
|
|
138
|
+
onRemove(agentName) {
|
|
139
|
+
removed.push(agentName);
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const conn: RuntimeConnection = {
|
|
145
|
+
sendPrompt: async () => {},
|
|
146
|
+
followUp: async () => {},
|
|
147
|
+
abort: async () => {},
|
|
148
|
+
getState: async (): Promise<ConnectionState> => ({ status: "idle" }),
|
|
149
|
+
close: () => {},
|
|
150
|
+
};
|
|
151
|
+
usedNames.push("non-headless-agent");
|
|
152
|
+
setConnection("non-headless-agent", conn);
|
|
153
|
+
removeConnection("non-headless-agent");
|
|
154
|
+
|
|
155
|
+
expect(removed).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("listener added after registration sees existing agents via catch-up", () => {
|
|
159
|
+
usedNames.push("preexisting-agent");
|
|
160
|
+
registerHeadlessConnection("preexisting-agent", { pid: 99999, stdin: makeStdin() });
|
|
161
|
+
|
|
162
|
+
const seen: string[] = [];
|
|
163
|
+
unsubscribers.push(
|
|
164
|
+
addHeadlessConnectionListener({
|
|
165
|
+
onRegister(agentName) {
|
|
166
|
+
seen.push(agentName);
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(seen).toContain("preexisting-agent");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("unsubscribe stops further notifications", () => {
|
|
175
|
+
const seen: string[] = [];
|
|
176
|
+
const unsub = addHeadlessConnectionListener({
|
|
177
|
+
onRegister(agentName) {
|
|
178
|
+
seen.push(agentName);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
unsub();
|
|
182
|
+
|
|
183
|
+
usedNames.push("after-unsub-agent");
|
|
184
|
+
registerHeadlessConnection("after-unsub-agent", { pid: 99999, stdin: makeStdin() });
|
|
185
|
+
|
|
186
|
+
expect(seen).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("onRegister receives the stdin handle from the spawned process", async () => {
|
|
190
|
+
const writes: string[] = [];
|
|
191
|
+
const stdin = {
|
|
192
|
+
write(data: string | Uint8Array) {
|
|
193
|
+
writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
194
|
+
return Promise.resolve(0);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const observed: Array<{ write(data: string | Uint8Array): number | Promise<number> }> = [];
|
|
199
|
+
unsubscribers.push(
|
|
200
|
+
addHeadlessConnectionListener({
|
|
201
|
+
onRegister(_agentName, s) {
|
|
202
|
+
observed.push(s);
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
usedNames.push("stdin-pass-agent");
|
|
208
|
+
registerHeadlessConnection("stdin-pass-agent", { pid: 99999, stdin });
|
|
209
|
+
expect(observed.length).toBe(1);
|
|
210
|
+
expect(observed[0]).toBe(stdin);
|
|
211
|
+
await observed[0]?.write("hello");
|
|
212
|
+
expect(writes).toEqual(["hello"]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module-level connection registry for active RuntimeConnection instances.
|
|
3
3
|
*
|
|
4
|
-
* Tracks RPC connections to headless agent processes (e.g., Sapling).
|
|
4
|
+
* Tracks RPC connections to headless agent processes (e.g., Sapling, headless Claude).
|
|
5
5
|
* Keyed by agent name — same namespace as AgentSession.agentName.
|
|
6
6
|
*
|
|
7
7
|
* Thread safety: single-threaded Bun runtime; no locking needed.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { HeadlessClaudeConnection } from "./headless-connection.ts";
|
|
10
11
|
import type { RuntimeConnection } from "./types.ts";
|
|
11
12
|
|
|
13
|
+
/** Writable handle exposed to headless connection listeners. */
|
|
14
|
+
export type HeadlessStdin = { write(data: string | Uint8Array): number | Promise<number> };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Listener fired on headless Claude connection lifecycle events.
|
|
18
|
+
* Fires only for connections created via registerHeadlessConnection() — Sapling
|
|
19
|
+
* and other RuntimeConnection registrants via setConnection() are not surfaced
|
|
20
|
+
* because their wire format differs from headless Claude's stream-json stdin.
|
|
21
|
+
*/
|
|
22
|
+
export interface HeadlessConnectionListener {
|
|
23
|
+
onRegister(agentName: string, stdin: HeadlessStdin): void;
|
|
24
|
+
onRemove?(agentName: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
const connections = new Map<string, RuntimeConnection>();
|
|
28
|
+
const headlessAgents = new Map<string, HeadlessStdin>();
|
|
29
|
+
const listeners = new Set<HeadlessConnectionListener>();
|
|
13
30
|
|
|
14
31
|
/** Retrieve the active connection for a given agent, or undefined if none. */
|
|
15
32
|
export function getConnection(agentName: string): RuntimeConnection | undefined {
|
|
@@ -27,8 +44,60 @@ export function setConnection(agentName: string, conn: RuntimeConnection): void
|
|
|
27
44
|
*/
|
|
28
45
|
export function removeConnection(agentName: string): void {
|
|
29
46
|
const conn = connections.get(agentName);
|
|
30
|
-
if (conn)
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
if (!conn) return;
|
|
48
|
+
if (headlessAgents.delete(agentName)) {
|
|
49
|
+
for (const listener of listeners) {
|
|
50
|
+
listener.onRemove?.(agentName);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
conn.close();
|
|
54
|
+
connections.delete(agentName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a HeadlessClaudeConnection from a spawned process handle and register it.
|
|
59
|
+
*
|
|
60
|
+
* Called by spawnHeadlessAgent() (process.ts) when an agentName is provided.
|
|
61
|
+
* The registered connection is retrievable via getConnection(agentName) for
|
|
62
|
+
* follow-up delivery, state polling, and abort — all without tmux.
|
|
63
|
+
*
|
|
64
|
+
* This is the sibling registration path to Sapling's connect() flow. It does NOT
|
|
65
|
+
* generalize RpcProcessHandle and does NOT touch other runtime adapters.
|
|
66
|
+
*
|
|
67
|
+
* @param agentName - Unique agent identifier (same namespace as AgentSession.agentName)
|
|
68
|
+
* @param proc - Spawned headless process with pid and stdin
|
|
69
|
+
* @returns The newly created and registered RuntimeConnection
|
|
70
|
+
*/
|
|
71
|
+
export function registerHeadlessConnection(
|
|
72
|
+
agentName: string,
|
|
73
|
+
proc: { pid: number; stdin: HeadlessStdin },
|
|
74
|
+
): RuntimeConnection {
|
|
75
|
+
const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
|
|
76
|
+
setConnection(agentName, conn);
|
|
77
|
+
headlessAgents.set(agentName, proc.stdin);
|
|
78
|
+
for (const listener of listeners) {
|
|
79
|
+
listener.onRegister(agentName, proc.stdin);
|
|
80
|
+
}
|
|
81
|
+
return conn;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subscribe to headless Claude connection lifecycle events.
|
|
86
|
+
*
|
|
87
|
+
* onRegister fires synchronously after registerHeadlessConnection() inserts the
|
|
88
|
+
* connection. onRemove fires synchronously before removeConnection() closes the
|
|
89
|
+
* connection. Listeners observing already-registered agents at subscribe time
|
|
90
|
+
* receive an immediate onRegister for each — this lets late subscribers (e.g.,
|
|
91
|
+
* runServe started after agents already exist) catch up without rescanning.
|
|
92
|
+
*
|
|
93
|
+
* @returns Unsubscribe function that removes this listener.
|
|
94
|
+
*/
|
|
95
|
+
export function addHeadlessConnectionListener(listener: HeadlessConnectionListener): () => void {
|
|
96
|
+
listeners.add(listener);
|
|
97
|
+
for (const [agentName, stdin] of headlessAgents) {
|
|
98
|
+
listener.onRegister(agentName, stdin);
|
|
33
99
|
}
|
|
100
|
+
return () => {
|
|
101
|
+
listeners.delete(listener);
|
|
102
|
+
};
|
|
34
103
|
}
|