brl-subagent 2.3.4

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
package/src/runner.ts ADDED
@@ -0,0 +1,877 @@
1
+ /**
2
+ * brl-subagent — Process Runner
3
+ *
4
+ * Spawns and manages subagent pi processes. Handles:
5
+ * - Process spawning with safe environment (F2)
6
+ * - JSON-line stdout parsing
7
+ * - Usage statistics accumulation
8
+ * - Abort signal and timeout handling
9
+ * - Temp file management
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import type { ChildProcess } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
+ import { wrapTask } from "./prompt";
19
+ import type {
20
+ SubagentResult,
21
+ SubagentToolOptions,
22
+ UsageStats,
23
+ ThinkingLevel,
24
+ } from "./types";
25
+ import {
26
+ EMPTY_USAGE,
27
+ SIGKILL_GRACE_MS,
28
+ TEMP_FILE_MODE,
29
+ MAX_TEMP_DIR_AGE_MS,
30
+ classifyError,
31
+ SUBAGENT_ABORTED_MESSAGE,
32
+ } from "./types";
33
+ import { getSafeEnv, DEPTH_ENV_KEY, sanitizeErrorMessage } from "./sanitize";
34
+ import type { Logger } from "./logging";
35
+ import type { Intercom } from "./messaging";
36
+ import { extractMessages, formatPendingMessages } from "./messaging";
37
+ import type {
38
+ TranscriptMessage,
39
+ TranscriptContentBlock,
40
+ TranscriptToolCallBlock,
41
+ } from "./transcript-tail";
42
+ // ---------------------------------------------------------------------------
43
+ // Pi binary resolution
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Resolve the pi binary and command-line invocation for subprocess spawning.
48
+ */
49
+ export function getPiInvocation(extraArgs: string[]): { command: string; args: string[] } {
50
+ const currentScript = process.argv[1];
51
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
52
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
53
+ return { command: process.execPath, args: [currentScript, ...extraArgs] };
54
+ }
55
+
56
+ const execName = path.basename(process.execPath).toLowerCase();
57
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
58
+ if (!isGenericRuntime) {
59
+ return { command: process.execPath, args: extraArgs };
60
+ }
61
+
62
+ return { command: "pi", args: extraArgs };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Temp file helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Get the subagent temp directory path for a given cwd.
71
+ */
72
+ function getTempBaseDir(cwd: string): string {
73
+ return path.join(cwd, ".pi", "subagent-tmp");
74
+ }
75
+
76
+ /**
77
+ * Scan .pi/subagent-tmp/ directory and remove any subdirectories whose mtime
78
+ * is older than maxAgeMs. Returns the count of removed directories.
79
+ * Handles missing directory gracefully (returns 0).
80
+ */
81
+ export async function cleanupTempDirs(
82
+ cwd: string,
83
+ maxAgeMs: number = MAX_TEMP_DIR_AGE_MS,
84
+ ): Promise<number> {
85
+ const baseDir = getTempBaseDir(cwd);
86
+ let removed = 0;
87
+
88
+ try {
89
+ const entries = await fs.promises.readdir(baseDir, { withFileTypes: true });
90
+ const now = Date.now();
91
+
92
+ for (const entry of entries) {
93
+ if (!entry.isDirectory()) continue;
94
+ const dirPath = path.join(baseDir, entry.name);
95
+ try {
96
+ const stat = await fs.promises.stat(dirPath);
97
+ if (now - stat.mtimeMs > maxAgeMs) {
98
+ await fs.promises.rm(dirPath, { recursive: true, force: true });
99
+ removed++;
100
+ }
101
+ } catch {
102
+ // Skip entries that disappear during iteration
103
+ }
104
+ }
105
+ } catch {
106
+ // Directory does not exist or is not accessible
107
+ }
108
+
109
+ return removed;
110
+ }
111
+
112
+ async function writeToTempFile(
113
+ cwd: string,
114
+ name: string,
115
+ content: string,
116
+ ): Promise<{ dir: string; filePath: string }> {
117
+ const baseDir = getTempBaseDir(cwd);
118
+ await fs.promises.mkdir(baseDir, { recursive: true });
119
+ const tmpDir = await fs.promises.mkdtemp(path.join(baseDir, `${name}-`));
120
+ const filePath = path.join(tmpDir, `${name}.md`);
121
+ await withFileMutationQueue(filePath, async () => {
122
+ await fs.promises.writeFile(filePath, content, {
123
+ encoding: "utf-8",
124
+ mode: TEMP_FILE_MODE,
125
+ });
126
+ });
127
+ return { dir: tmpDir, filePath };
128
+ }
129
+
130
+ function cleanupTempDir(dir: string, filePath: string): void {
131
+ try {
132
+ fs.unlinkSync(filePath);
133
+ } catch {
134
+ /* ignore */
135
+ }
136
+ try {
137
+ fs.rmdirSync(dir);
138
+ } catch {
139
+ /* ignore */
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Argument construction
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export function buildSubagentArgs(
148
+ model: { provider: string; id: string },
149
+ thinkingLevel: ThinkingLevel,
150
+ toolOptions?: SubagentToolOptions,
151
+ ): string[] {
152
+ const args: string[] = [
153
+ "--mode",
154
+ "json",
155
+ "-p",
156
+ "--no-session",
157
+ "--model",
158
+ `${model.provider}/${model.id}`,
159
+ "--thinking",
160
+ thinkingLevel,
161
+ ];
162
+ if (toolOptions?.noBuiltinTools) {
163
+ args.push("--no-builtin-tools");
164
+ } else if (toolOptions?.tools && toolOptions.tools.length > 0) {
165
+ args.push("--tools", toolOptions.tools.join(","));
166
+ }
167
+ if (toolOptions?.excludeTools && toolOptions.excludeTools.length > 0) {
168
+ args.push("--exclude-tools", toolOptions.excludeTools.join(","));
169
+ }
170
+ return args;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Usage tracking
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export function accumulateUsage(
178
+ target: UsageStats,
179
+ src: {
180
+ input?: number;
181
+ output?: number;
182
+ cacheRead?: number;
183
+ cacheWrite?: number;
184
+ cost?: { total?: number };
185
+ totalTokens?: number;
186
+ } | undefined,
187
+ ): void {
188
+ if (!src) return;
189
+ target.turns++;
190
+ target.input += src.input ?? 0;
191
+ target.output += src.output ?? 0;
192
+ target.cacheRead += src.cacheRead ?? 0;
193
+ target.cacheWrite += src.cacheWrite ?? 0;
194
+ if (src.cost && typeof src.cost.total === "number") {
195
+ target.cost += src.cost.total;
196
+ }
197
+ if (src.totalTokens) {
198
+ target.contextTokens = src.totalTokens;
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Abort handler
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function attachAbortHandler(
207
+ proc: ChildProcess,
208
+ signal: AbortSignal,
209
+ result: SubagentResult,
210
+ ): void {
211
+ const killProc = () => {
212
+ // Issue #120 (Track 1): stamp the abort source HONESTLY before the kill so
213
+ // a user/cancel/stop_subagent run records an 'aborted' category instead of
214
+ // the masked empty-errorMessage/'unknown' that previously made our own
215
+ // SIGTERM indistinguishable from a provider-side failure (Class 2). The
216
+ // stamped message + category survive the classifyError re-run at the tail
217
+ // of runSubagent (the "aborted" pattern) — before this, the abort was a
218
+ // blind kill with no result access at all.
219
+ result.errorMessage = SUBAGENT_ABORTED_MESSAGE;
220
+ result.errorCategory = "aborted";
221
+ proc.kill("SIGTERM");
222
+ setTimeout(() => {
223
+ if (!proc.killed) proc.kill("SIGKILL");
224
+ }, SIGKILL_GRACE_MS);
225
+ };
226
+ if (signal.aborted) killProc();
227
+ else signal.addEventListener("abort", killProc, { once: true });
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Stdout parsing
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function emitSubagentUpdate(
235
+ result: SubagentResult,
236
+ onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
237
+ getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
238
+ ): void {
239
+ if (!onUpdate) return;
240
+ onUpdate({
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: getFinalOutputFn(result.messages) || "(running...)",
245
+ },
246
+ ],
247
+ details: { ...result },
248
+ });
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Live transcript capture (issue #105 — foreground drill-in parity)
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Foreground subagents stream thinking/text/toolCall deltas via `message_update`
257
+ * JSONL events. Previously discarded, these are now accumulated into a live
258
+ * transcript tail (`result.liveTranscript`) that the drill-in overlay renders —
259
+ * parity with the background path's `_sessionRef` transcript.
260
+ *
261
+ * Event shapes EMPIRICALLY VERIFIED (pi 0.84.2 probe runs):
262
+ * { "type": "message_update", "usage": {...},
263
+ * "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, "delta": "PRO" } }
264
+ * text/thinking: *_start {contentIndex} · *_delta {contentIndex, delta} ·
265
+ * *_end {contentIndex, content}
266
+ * toolcall: toolcall_start {contentIndex} · toolcall_delta {contentIndex,
267
+ * delta = partial args JSON} · toolcall_end {contentIndex,
268
+ * toolCall: { name, arguments }}
269
+ */
270
+
271
+ /** Tail cap: keep at most this many transcript messages per run. */
272
+ export const LIVE_TRANSCRIPT_MAX_MESSAGES = 40;
273
+ /** Approximate byte budget for the captured transcript tail. */
274
+ export const LIVE_TRANSCRIPT_MAX_BYTES = 64 * 1024;
275
+ /** Min interval between throttled live updates (~5/sec; the drill-in ticks at 200ms). */
276
+ export const LIVE_UPDATE_MIN_INTERVAL_MS = 200;
277
+
278
+ type LiveBlockKind = "text" | "thinking" | "toolCall";
279
+
280
+ /** Per-result streaming-accumulation state (keyed by the result object). */
281
+ interface LiveTranscriptBuilder {
282
+ /** Completed messages + the in-flight assistant message (last element). */
283
+ messages: TranscriptMessage[];
284
+ /** Approximate byte cost of `messages` (recomputed at block boundaries). */
285
+ bytes: number;
286
+ /** True while an assistant message is being streamed (not yet finalized). */
287
+ streamingOpen: boolean;
288
+ /** Timestamp of the last emitted live update (throttle). */
289
+ lastEmit: number;
290
+ }
291
+
292
+ const liveBuilders = new WeakMap<SubagentResult, LiveTranscriptBuilder>();
293
+
294
+ function getLiveBuilder(result: SubagentResult): LiveTranscriptBuilder {
295
+ let builder = liveBuilders.get(result);
296
+ if (!builder) {
297
+ builder = {
298
+ messages: [],
299
+ bytes: 0,
300
+ streamingOpen: false,
301
+ lastEmit: 0,
302
+ };
303
+ liveBuilders.set(result, builder);
304
+ }
305
+ return builder;
306
+ }
307
+
308
+ /**
309
+ * Convert an authoritative `message_end` message into the transcript shape the
310
+ * drill-in planner understands (role + content blocks of text/thinking/toolCall).
311
+ */
312
+ export function toTranscriptMessage(msg: Record<string, unknown>): TranscriptMessage {
313
+ const role = typeof msg.role === "string" ? msg.role : undefined;
314
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
315
+ const content = Array.isArray(msg.content) ? msg.content : undefined;
316
+ if (!content) {
317
+ return { role, ...(toolName ? { toolName } : {}) };
318
+ }
319
+ const blocks: TranscriptContentBlock[] = [];
320
+ for (const part of content) {
321
+ if (!part || typeof part !== "object") continue;
322
+ const p = part as Record<string, unknown>;
323
+ if (p.type === "text" && typeof p.text === "string") {
324
+ blocks.push({ type: "text", text: p.text });
325
+ } else if (p.type === "thinking" && typeof p.thinking === "string") {
326
+ blocks.push({ type: "thinking", thinking: p.thinking });
327
+ } else if (p.type === "toolCall") {
328
+ blocks.push({
329
+ type: "toolCall",
330
+ name: typeof p.name === "string" ? p.name : undefined,
331
+ arguments: p.arguments,
332
+ });
333
+ }
334
+ }
335
+ return { role, content: blocks, ...(toolName ? { toolName } : {}) };
336
+ }
337
+
338
+ /** Serialized size of one transcript message (same accounting as recomputeLiveBytes). */
339
+ function messageSize(m: TranscriptMessage): number {
340
+ try {
341
+ return JSON.stringify(m)?.length ?? 0;
342
+ } catch {
343
+ /* non-serializable block — ignore */
344
+ return 0;
345
+ }
346
+ }
347
+
348
+ function recomputeLiveBytes(builder: LiveTranscriptBuilder): void {
349
+ let total = 0;
350
+ for (const m of builder.messages) {
351
+ total += messageSize(m);
352
+ }
353
+ builder.bytes = total;
354
+ }
355
+
356
+ /** Enforce the ring-buffer tail cap — oldest messages dropped, in-flight kept. */
357
+ function trimLiveTranscript(builder: LiveTranscriptBuilder): void {
358
+ while (
359
+ builder.messages.length > LIVE_TRANSCRIPT_MAX_MESSAGES &&
360
+ builder.messages.length > 1
361
+ ) {
362
+ builder.messages.shift();
363
+ }
364
+ while (builder.bytes > LIVE_TRANSCRIPT_MAX_BYTES && builder.messages.length > 1) {
365
+ // Review R1: the byte budget must shrink as messages drop — the OLD code
366
+ // re-read builder.bytes (never decremented), so once over the cap it kept
367
+ // shifting until messages.length === 1, collapsing the drill-in to the
368
+ // last message only (reproduced: 8×~10KB → length 3, ~10KB kept).
369
+ const dropped = builder.messages.shift();
370
+ if (dropped) builder.bytes -= messageSize(dropped);
371
+ }
372
+ recomputeLiveBytes(builder);
373
+ }
374
+
375
+ /** Return the in-flight assistant message, creating a new one if needed. */
376
+ function ensureInFlightAssistant(builder: LiveTranscriptBuilder): TranscriptMessage {
377
+ const last = builder.messages[builder.messages.length - 1];
378
+ if (last && last.role === "assistant" && builder.streamingOpen) {
379
+ return last;
380
+ }
381
+ builder.streamingOpen = true;
382
+ const msg: TranscriptMessage = { role: "assistant", content: [] };
383
+ builder.messages.push(msg);
384
+ return msg;
385
+ }
386
+
387
+ /**
388
+ * Get (or create) the block at `contentIndex` of the in-flight assistant
389
+ * message. Placeholders fill gaps so arbitrary contentIndex order works; the
390
+ * drill-in planner skips empty placeholder blocks.
391
+ */
392
+ function getOrCreateBlock(
393
+ builder: LiveTranscriptBuilder,
394
+ index: number,
395
+ kind: LiveBlockKind,
396
+ ): TranscriptContentBlock {
397
+ const msg = ensureInFlightAssistant(builder);
398
+ if (!Array.isArray(msg.content)) msg.content = [];
399
+ const content = msg.content as TranscriptContentBlock[];
400
+ while (content.length <= index) {
401
+ content.push({ type: "thinking", thinking: "" });
402
+ }
403
+ const existing = content[index];
404
+ const wantType = kind === "toolCall" ? "toolCall" : kind;
405
+ if (!existing || typeof existing !== "object" || existing.type !== wantType) {
406
+ const block: TranscriptContentBlock =
407
+ kind === "toolCall"
408
+ ? { type: "toolCall", name: "", arguments: "" }
409
+ : kind === "text"
410
+ ? { type: "text", text: "" }
411
+ : { type: "thinking", thinking: "" };
412
+ content[index] = block;
413
+ return block;
414
+ }
415
+ return existing;
416
+ }
417
+
418
+ /** Append `delta` to a text/thinking block field; returns the added chars. */
419
+ function appendDeltaText(
420
+ block: TranscriptContentBlock,
421
+ field: "text" | "thinking",
422
+ delta: string,
423
+ ): number {
424
+ const rec = block as unknown as Record<string, unknown>;
425
+ const prev = typeof rec[field] === "string" ? (rec[field] as string) : "";
426
+ rec[field] = prev + delta;
427
+ return delta.length;
428
+ }
429
+
430
+ /** Replace a text/thinking block field with the authoritative `content`. */
431
+ function setDeltaContent(
432
+ block: TranscriptContentBlock,
433
+ field: "text" | "thinking",
434
+ content: string,
435
+ ): number {
436
+ const rec = block as unknown as Record<string, unknown>;
437
+ const prev = typeof rec[field] === "string" ? (rec[field] as string) : "";
438
+ rec[field] = content;
439
+ return content.length - prev.length;
440
+ }
441
+
442
+ /**
443
+ * Throttled live-transcript emission. Block boundaries (start/end) force an
444
+ * update; mid-block deltas are throttled to ~5/sec — the drill-in monitor
445
+ * re-reads state at a 200ms tick, so per-delta emissions would be wasteful
446
+ * and would flood the conductor's update channel.
447
+ */
448
+ function emitLiveUpdate(
449
+ result: SubagentResult,
450
+ onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
451
+ getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
452
+ builder: LiveTranscriptBuilder,
453
+ force: boolean,
454
+ ): void {
455
+ if (!onUpdate) return;
456
+ const now = Date.now();
457
+ if (!force && now - builder.lastEmit < LIVE_UPDATE_MIN_INTERVAL_MS) return;
458
+ builder.lastEmit = now;
459
+ result.liveTranscript = builder.messages;
460
+ emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
461
+ }
462
+
463
+ /**
464
+ * Handle one `message_update` JSONL event: apply the delta to the in-flight
465
+ * assistant message and (throttled) emit a live update.
466
+ */
467
+ function handleMessageUpdate(
468
+ event: Record<string, unknown>,
469
+ result: SubagentResult,
470
+ onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
471
+ getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
472
+ ): void {
473
+ const deltaEvent = event.assistantMessageEvent as Record<string, unknown> | undefined;
474
+ if (!deltaEvent || typeof deltaEvent.type !== "string") return;
475
+
476
+ const type = deltaEvent.type;
477
+ const index =
478
+ typeof deltaEvent.contentIndex === "number" ? deltaEvent.contentIndex : 0;
479
+ const builder = getLiveBuilder(result);
480
+
481
+ switch (type) {
482
+ case "text_start":
483
+ case "thinking_start":
484
+ case "toolcall_start": {
485
+ const kind: LiveBlockKind =
486
+ type === "text_start"
487
+ ? "text"
488
+ : type === "thinking_start"
489
+ ? "thinking"
490
+ : "toolCall";
491
+ getOrCreateBlock(builder, index, kind);
492
+ emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
493
+ return;
494
+ }
495
+ case "text_delta":
496
+ case "thinking_delta": {
497
+ if (typeof deltaEvent.delta !== "string" || deltaEvent.delta === "") return;
498
+ const block = getOrCreateBlock(
499
+ builder,
500
+ index,
501
+ type === "text_delta" ? "text" : "thinking",
502
+ );
503
+ builder.bytes += appendDeltaText(
504
+ block,
505
+ type === "text_delta" ? "text" : "thinking",
506
+ deltaEvent.delta,
507
+ );
508
+ emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, false);
509
+ return;
510
+ }
511
+ case "text_end":
512
+ case "thinking_end": {
513
+ if (typeof deltaEvent.content !== "string") return;
514
+ const block = getOrCreateBlock(
515
+ builder,
516
+ index,
517
+ type === "text_end" ? "text" : "thinking",
518
+ );
519
+ builder.bytes += setDeltaContent(
520
+ block,
521
+ type === "text_end" ? "text" : "thinking",
522
+ deltaEvent.content,
523
+ );
524
+ emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
525
+ return;
526
+ }
527
+ case "toolcall_delta": {
528
+ // Empirically: `delta` carries PARTIAL JSON of the tool arguments.
529
+ if (typeof deltaEvent.delta !== "string" || deltaEvent.delta === "") return;
530
+ const block = getOrCreateBlock(builder, index, "toolCall") as TranscriptToolCallBlock;
531
+ const prev = typeof block.arguments === "string" ? block.arguments : "";
532
+ block.arguments = prev + deltaEvent.delta;
533
+ builder.bytes += deltaEvent.delta.length;
534
+ emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, false);
535
+ return;
536
+ }
537
+ case "toolcall_end": {
538
+ // Empirically: carries the FULL ToolCall — name + parsed arguments.
539
+ const block = getOrCreateBlock(builder, index, "toolCall") as TranscriptToolCallBlock;
540
+ const toolCall = deltaEvent.toolCall as Record<string, unknown> | undefined;
541
+ if (block && toolCall) {
542
+ if (typeof toolCall.name === "string") block.name = toolCall.name;
543
+ if (toolCall.arguments !== undefined) {
544
+ const prevLen = typeof block.arguments === "string" ? block.arguments.length : 0;
545
+ let newLen = 0;
546
+ try {
547
+ newLen = JSON.stringify(toolCall.arguments)?.length ?? 0;
548
+ } catch {
549
+ newLen = 0;
550
+ }
551
+ builder.bytes += newLen - prevLen;
552
+ block.arguments = toolCall.arguments;
553
+ }
554
+ }
555
+ emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
556
+ return;
557
+ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * On assistant `message_end`: replace the delta-built in-flight message with
563
+ * the authoritative message (deltas may have missed trailing blocks), then
564
+ * enforce the tail caps. Purely additive — existing message_end semantics
565
+ * (messages/usage/update emission) are untouched.
566
+ */
567
+ function finalizeLiveAssistantMessage(
568
+ result: SubagentResult,
569
+ builder: LiveTranscriptBuilder,
570
+ msg: Record<string, unknown>,
571
+ ): void {
572
+ const authoritative = toTranscriptMessage(msg);
573
+ const last = builder.messages[builder.messages.length - 1];
574
+ if (last && last.role === "assistant" && builder.streamingOpen) {
575
+ builder.messages[builder.messages.length - 1] = authoritative;
576
+ } else {
577
+ builder.messages.push(authoritative);
578
+ }
579
+ builder.streamingOpen = false;
580
+ trimLiveTranscript(builder);
581
+ result.liveTranscript = builder.messages;
582
+ }
583
+
584
+ /** Append a completed non-assistant message (user echo / toolResult). */
585
+ function pushLiveTranscriptMessage(
586
+ result: SubagentResult,
587
+ builder: LiveTranscriptBuilder,
588
+ msg: Record<string, unknown>,
589
+ ): void {
590
+ builder.messages.push(toTranscriptMessage(msg));
591
+ trimLiveTranscript(builder);
592
+ result.liveTranscript = builder.messages;
593
+ }
594
+
595
+ export function parseSubagentLine(
596
+ line: string,
597
+ result: SubagentResult,
598
+ onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
599
+ getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
600
+ log?: Logger,
601
+ ): void {
602
+ if (!line.trim()) return;
603
+ let event: Record<string, unknown>;
604
+ try {
605
+ event = JSON.parse(line) as Record<string, unknown>;
606
+ } catch {
607
+ const snippet = line.trim().slice(0, 200);
608
+ result.stderr += `[parse error] ${snippet}\n`;
609
+ log?.warn("Failed to parse subagent stdout line", { snippet });
610
+ return;
611
+ }
612
+
613
+ if (event.type === "message_update") {
614
+ handleMessageUpdate(event, result, onUpdate, getFinalOutputFn);
615
+ return;
616
+ }
617
+
618
+ if (event.type === "message_end" && event.message) {
619
+ const msg = event.message as Record<string, unknown>;
620
+ result.messages.push(msg);
621
+
622
+ if (msg.role === "assistant") {
623
+ const usage = msg.usage as Parameters<typeof accumulateUsage>[1];
624
+ accumulateUsage(result.usage, usage);
625
+ if (!result.model && msg.model) result.model = msg.model as string;
626
+ if (msg.stopReason) result.stopReason = msg.stopReason as string;
627
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage as string;
628
+
629
+ log?.debug("Subagent message completed", {
630
+ model: result.model,
631
+ stopReason: result.stopReason,
632
+ tokensIn: result.usage.input,
633
+ tokensOut: result.usage.output,
634
+ });
635
+
636
+ // Issue #105: sync the live transcript with the authoritative message
637
+ // (replaces the delta-built in-flight assistant message).
638
+ finalizeLiveAssistantMessage(result, getLiveBuilder(result), msg);
639
+ } else {
640
+ // Issue #105: keep the user echo / toolResult in the live transcript.
641
+ pushLiveTranscriptMessage(result, getLiveBuilder(result), msg);
642
+ }
643
+
644
+ emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
645
+ }
646
+
647
+ if (event.type === "tool_result_end" && event.message) {
648
+ const msg = event.message as Record<string, unknown>;
649
+ result.messages.push(msg);
650
+ // Issue #105: keep the toolResult echo in the live transcript.
651
+ pushLiveTranscriptMessage(result, getLiveBuilder(result), msg);
652
+ emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
653
+ }
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // Provider-status diagnostics (issue #120, Track 2)
658
+ // ---------------------------------------------------------------------------
659
+
660
+ /**
661
+ * Count the assistant turns a session produced with stopReason "error" — pi's
662
+ * "N strikes" signal. When a subagent burns multiple model-error turns before
663
+ * giving up, the failure is almost certainly provider-side (Class 1: connection
664
+ * / HTTP / missing-model errors) and NOT one of our kill paths (Class 2).
665
+ */
666
+ function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
667
+ let count = 0;
668
+ for (const msg of messages) {
669
+ if (msg.role === "assistant" && msg.stopReason === "error") count++;
670
+ }
671
+ return count;
672
+ }
673
+
674
+ /**
675
+ * Issue #120 (Track 2): capture provider-status diagnostics at the subagent
676
+ * output boundary with an honest fallback.
677
+ *
678
+ * RECON (deciding fact for the fallback): the raw HTTP status behind a
679
+ * provider-side failure ("Connection error.") is NOT reachable as a separately
680
+ * queried field on the `message_end` payload. pi's OpenAI-compat stream builds
681
+ * the assistant `output` object as { role, content, api, provider, model,
682
+ * usage, stopReason, timestamp, errorMessage } — there is no `status` field. The
683
+ * status+body pi DOES extract is folded into the composed `errorMessage` string
684
+ * by `formatOpenAIResponsesError` → `formatProviderError` (e.g. "OpenAI API
685
+ * error (429): ..."), and a pure connection error (no extractable HTTP status)
686
+ * produces a bare "Connection error." with no status at all. `buildSubagentArgs`
687
+ * runs `--mode json` — there is no verbose/error-detail flag that would surface
688
+ * a richer raw error object at the boundary.
689
+ *
690
+ * So we capture the AVAILABLE self-diagnostic datums instead, appended to the
691
+ * failure record's errorMessage (which persists to the run record via
692
+ * finalizeRunRecord): the model-error turn count plus a concise failure-turn
693
+ * context summary. Sanitized (F7) so any URL/path the provider embedded in its
694
+ * error text never leaks into the main agent's context or the persisted record.
695
+ */
696
+ export function enrichFailureDiagnostics(result: SubagentResult, cwd?: string): void {
697
+ if (!result.errorMessage) return;
698
+ // Track 1 already stamped an honest source (user abort / timeout) — that is
699
+ // authoritative and must not be overlaid with the provider-error fallback.
700
+ const msg = result.errorMessage.toLowerCase();
701
+ if (msg.includes("aborted") || msg.includes("timed out")) return;
702
+ const errorTurns = countModelErrorTurns(result.messages);
703
+ if (errorTurns === 0) return;
704
+
705
+ const suffix = ` (after ${errorTurns} provider error turn${errorTurns > 1 ? "s" : ""})`;
706
+ // Do not double-append if the enrichment already ran (idempotent for
707
+ // reclassification / retry paths that reuse the result object).
708
+ if (result.errorMessage.endsWith(suffix)) return;
709
+
710
+ const combined = sanitizeErrorMessage(`${result.errorMessage}${suffix}`, cwd);
711
+ result.errorMessage = combined;
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Process runner
716
+ // ---------------------------------------------------------------------------
717
+
718
+ export async function runSubagent(
719
+ cwd: string,
720
+ systemPrompt: string,
721
+ model: { provider: string; id: string },
722
+ thinkingLevel: ThinkingLevel,
723
+ task: string,
724
+ signal: AbortSignal | undefined,
725
+ onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
726
+ toolOptions: SubagentToolOptions | undefined,
727
+ timeout: number | undefined,
728
+ getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
729
+ log?: Logger,
730
+ depth?: number,
731
+ intercom?: Intercom,
732
+ subagentId?: string,
733
+ ): Promise<SubagentResult> {
734
+ // E10: Inject pending intercom messages into the task prompt
735
+ if (intercom && subagentId && intercom.hasMessages(subagentId)) {
736
+ const pending = intercom.receiveAndClear(subagentId);
737
+ if (pending.length > 0) {
738
+ const msgBlock = formatPendingMessages(pending);
739
+ task += `
740
+
741
+ Pending messages from other subagents:
742
+ ${msgBlock}`;
743
+ log?.debug("Injected pending intercom messages", { subagentId, count: pending.length });
744
+ }
745
+ }
746
+
747
+ const args = buildSubagentArgs(model, thinkingLevel, toolOptions);
748
+
749
+ let tmpDir: string | null = null;
750
+ let tmpFilePath: string | null = null;
751
+
752
+ if (systemPrompt.trim()) {
753
+ const tmp = await writeToTempFile(cwd, "system", systemPrompt);
754
+ tmpDir = tmp.dir;
755
+ tmpFilePath = tmp.filePath;
756
+ args.push("--append-system-prompt", tmpFilePath);
757
+ }
758
+
759
+ // Pass the effective task as the prompt argument
760
+ // F27: wrap in the task-as-data fence — the subagent's user message
761
+ // is DATA, not instructions (see SUBAGENT_INSTRUCTIONS Task Boundary).
762
+ args.push(wrapTask(task));
763
+
764
+ const result: SubagentResult = {
765
+ messages: [],
766
+ usage: { ...EMPTY_USAGE },
767
+ exitCode: 0,
768
+ stderr: "",
769
+ };
770
+
771
+ log?.info("Starting subagent process", {
772
+ model: `${model.provider}/${model.id}`,
773
+ thinkingLevel,
774
+ cwd,
775
+ taskPreview: task.slice(0, 80),
776
+ hasSystemPrompt: systemPrompt.trim().length > 0,
777
+ timeout,
778
+ });
779
+
780
+ try {
781
+ const exitCode = await new Promise<number>((resolve) => {
782
+ const invocation = getPiInvocation(args);
783
+ const subDepth = depth !== undefined ? depth : undefined;
784
+ const envOverrides: Record<string, string> | undefined =
785
+ subDepth !== undefined ? { [DEPTH_ENV_KEY]: String(subDepth) } : undefined;
786
+ const proc = spawn(invocation.command, invocation.args, {
787
+ cwd,
788
+ shell: false,
789
+ stdio: ["ignore", "pipe", "pipe"],
790
+ env: getSafeEnv(envOverrides), // F2: Environment isolation + depth tracking
791
+ });
792
+
793
+ let buffer = "";
794
+
795
+ proc.stdout.on("data", (data: Buffer) => {
796
+ buffer += data.toString();
797
+ const lines = buffer.split("\n");
798
+ buffer = lines.pop() ?? "";
799
+ for (const line of lines) {
800
+ parseSubagentLine(line, result, onUpdate, getFinalOutputFn, log);
801
+ }
802
+ });
803
+
804
+ proc.stderr.on("data", (data: Buffer) => {
805
+ result.stderr += data.toString();
806
+ });
807
+
808
+ proc.on("close", (code) => {
809
+ if (buffer.trim()) {
810
+ parseSubagentLine(buffer, result, onUpdate, getFinalOutputFn, log);
811
+ }
812
+ log?.info("Subagent process exited", { exitCode: code, pid: proc.pid });
813
+ resolve(code ?? 0);
814
+ });
815
+
816
+ proc.on("error", (err) => {
817
+ // F7 (issue #30): subprocess errors can embed the spawn command
818
+ // (absolute paths to the pi binary, temp files, cwd) — sanitize the
819
+ // errorMessage before it reaches the conductor. stderr is left RAW:
820
+ // it is the subagent's own output (already in-scope of the subagent's
821
+ // context), and the conductor sees it in full via the result. Only
822
+ // OUR error text (errorMessage) is path-sanitized.
823
+ result.errorMessage = `Subprocess error: ${sanitizeErrorMessage(err.message, cwd)}`;
824
+ result.stderr += err.message;
825
+ log?.error("Subagent process error", { error: err.message });
826
+ resolve(1);
827
+ });
828
+
829
+ if (signal) {
830
+ attachAbortHandler(proc, signal, result);
831
+ }
832
+
833
+ if (timeout && timeout > 0) {
834
+ const timer = setTimeout(() => {
835
+ result.errorMessage = `Subagent timed out after ${timeout}ms`;
836
+ log?.warn("Subagent timed out", { timeout, pid: proc.pid });
837
+ proc.kill("SIGTERM");
838
+ setTimeout(() => {
839
+ if (!proc.killed) proc.kill("SIGKILL");
840
+ }, SIGKILL_GRACE_MS);
841
+ }, timeout);
842
+ proc.on("close", () => clearTimeout(timer));
843
+ }
844
+ });
845
+
846
+ result.exitCode = exitCode;
847
+ // Issue #120 (Track 2): capture whatever provider-status diagnostics are
848
+ // reachable at the subagent-output boundary (recon: the raw HTTP status is
849
+ // not a separate field — see enrichFailureDiagnostics) — with an honest
850
+ // fallback so the next occurrence still self-diagnoses. Runs BEFORE
851
+ // classifyError so the appended context is included in the category.
852
+ enrichFailureDiagnostics(result, cwd);
853
+ result.errorCategory = classifyError(result);
854
+ } finally {
855
+ if (tmpDir && tmpFilePath) {
856
+ cleanupTempDir(tmpDir, tmpFilePath);
857
+ }
858
+ }
859
+
860
+ // E10: Extract outgoing intercom messages from output
861
+ if (intercom && subagentId) {
862
+ const finalOutput = getFinalOutputFn(result.messages);
863
+ const outgoing = extractMessages(finalOutput);
864
+ for (const msg of outgoing) {
865
+ if (msg.target === "*") {
866
+ intercom.broadcast(subagentId, msg.content);
867
+ } else {
868
+ intercom.send(subagentId, msg.target, msg.content);
869
+ }
870
+ }
871
+ if (outgoing.length > 0) {
872
+ log?.debug("Extracted outgoing intercom messages", { subagentId, count: outgoing.length });
873
+ }
874
+ }
875
+
876
+ return result;
877
+ }