@pi-archimedes/subagent 1.3.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,9 +11,10 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "1.3.0"
14
+ "@pi-archimedes/core": "1.3.2"
15
15
  },
16
16
  "peerDependencies": {
17
+ "@earendil-works/pi-ai": ">=0.1.0",
17
18
  "@earendil-works/pi-coding-agent": ">=0.1.0",
18
19
  "@earendil-works/pi-tui": ">=0.1.0",
19
20
  "typebox": ">=1.1.0"
@@ -21,7 +22,7 @@
21
22
  "devDependencies": {
22
23
  "@types/node": "^22.0.0",
23
24
  "typebox": "^1.1.38",
24
- "typescript": "^6.0.0"
25
+ "typescript": "^6.0.3"
25
26
  },
26
27
  "pi": {
27
28
  "extensions": [
package/src/handlers.ts CHANGED
@@ -67,20 +67,23 @@ export function handleToolEnd(state: StreamState): void {
67
67
  }
68
68
 
69
69
  /**
70
- * Handle a tool_result_end event — capture tool output for live display.
70
+ * Handle a tool_execution_end event — capture tool result output for live display.
71
+ * The result is in event.result (the tool's return value).
71
72
  */
72
73
  export function handleToolResult(state: StreamState, event: JsonEvent): void {
73
- const toolMessage = event.message as Record<string, unknown> | undefined;
74
- if (!toolMessage || toolMessage.role !== "toolResult") return;
74
+ const result = event.result as Record<string, unknown> | undefined;
75
+ if (!result) return;
75
76
 
76
- const toolContent = toolMessage.content as Array<Record<string, unknown>> | string | undefined;
77
- const toolName = (toolMessage.toolName as string) ?? "tool";
77
+ const toolName = (event.toolName as string) ?? "tool";
78
78
 
79
- if (typeof toolContent === "string" && toolContent.trim()) {
80
- const lines = toolContent.split("\n").filter((l) => l.trim());
79
+ // Extract text content from tool result
80
+ const content = result.content as Array<Record<string, unknown>> | string | undefined;
81
+
82
+ if (typeof content === "string" && content.trim()) {
83
+ const lines = content.split("\n").filter((l) => l.trim());
81
84
  state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
82
- } else if (Array.isArray(toolContent)) {
83
- for (const part of toolContent) {
85
+ } else if (Array.isArray(content)) {
86
+ for (const part of content) {
84
87
  if (part.type === "text" && (part.text as string)?.trim()) {
85
88
  const text = part.text as string;
86
89
  const lines = text.split("\n").filter((l) => l.trim());
package/src/index.ts CHANGED
@@ -98,7 +98,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
98
98
  agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
99
99
  task: t.task,
100
100
  model: t.model,
101
- activeModel: ctx.model?.id,
101
+ activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
102
102
  cwd: t.cwd ?? undefined,
103
103
  })),
104
104
  signal: signal ?? undefined,
@@ -148,7 +148,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
148
148
  agentConfig,
149
149
  task: params.task,
150
150
  model: params.model,
151
- activeModel: ctx.model?.id,
151
+ activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
152
152
  cwd: params.cwd ?? undefined,
153
153
  signal: signal ?? undefined,
154
154
  onUpdate: (progress: SubagentProgress) => {
package/src/spawn.ts CHANGED
@@ -1,23 +1,12 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { execSync } from "node:child_process";
3
- import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
4
- import { createServer, type Socket } from "node:net";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
2
+ import * as fs from "node:fs";
3
+ import * as net from "node:net";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
7
  import { getBus, Events } from "@pi-archimedes/core/bus";
8
8
  import type { AgentConfig } from "./agents.js";
9
9
 
10
- // Track all temp dirs for cleanup on process exit (graceful shutdown only).
11
- // Note: SIGKILL/OOM cannot be caught — the exit handler only fires for
12
- // normal exits and catchable signals (SIGTERM, SIGINT, SIGHUP, etc.).
13
- const tempDirs = new Set<string>();
14
-
15
- process.on("exit", () => {
16
- for (const dir of tempDirs) {
17
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
18
- }
19
- });
20
-
21
10
  export interface SpawnOptions {
22
11
  task: string;
23
12
  model: string | undefined;
@@ -28,209 +17,216 @@ export interface SpawnOptions {
28
17
  }
29
18
 
30
19
  /**
31
- * Resolve the `pi` command — finds it on PATH.
20
+ * Resolve the pi binary path.
21
+ *
22
+ * Walk up from process.argv[1] (the pi CLI entry point) looking for the
23
+ * @earendil-works/pi-coding-agent package root, then resolve its bin.pi field.
24
+ * Falls back to "pi" (PATH lookup) if resolution fails.
32
25
  */
33
- export function resolvePiCommand(): { command: string; args: string[] } {
34
- let piPath: string;
26
+ function resolvePiBinary(): string {
35
27
  try {
36
- piPath = execSync(process.platform === "win32" ? 'where pi' : 'which pi', {
37
- encoding: "utf-8",
38
- }).trim();
39
- } catch {
40
- throw new Error("pi command not found on PATH. Is pi installed?");
41
- }
42
-
43
- // On Windows, the CLI might be a .cmd script
44
- if (process.platform === "win32" && !piPath.endsWith(".cmd") && !piPath.endsWith(".exe")) {
45
- const cmdPath = piPath + ".cmd";
46
- if (existsSync(cmdPath)) {
47
- return { command: "cmd", args: ["/c", cmdPath] };
28
+ const entry = process.argv[1];
29
+ if (!entry) return "pi";
30
+
31
+ let dir = path.dirname(fs.realpathSync(entry));
32
+ const root = path.parse(dir).root;
33
+
34
+ while (dir !== root) {
35
+ const pkgPath = path.join(dir, "package.json");
36
+ try {
37
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
38
+ name?: string;
39
+ bin?: string | Record<string, string>;
40
+ };
41
+ if (pkg.name === "@earendil-works/pi-coding-agent") {
42
+ const binField = pkg.bin;
43
+ const binRelative =
44
+ typeof binField === "string"
45
+ ? binField
46
+ : binField?.pi ?? Object.values(binField ?? {})[0];
47
+ if (binRelative) {
48
+ const resolved = path.resolve(dir, binRelative);
49
+ if (fs.existsSync(resolved)) return resolved;
50
+ }
51
+ break;
52
+ }
53
+ } catch {
54
+ // package.json missing or invalid — keep walking
55
+ }
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir) break;
58
+ dir = parent;
48
59
  }
60
+ } catch {
61
+ // fall through
49
62
  }
50
- return { command: piPath, args: [] };
63
+ return "pi";
51
64
  }
52
65
 
53
66
  /**
54
- * Write system prompt to a temp file for --append-system-prompt.
67
+ * Start a Unix socket server that bridges the child's ask tool to the parent bus.
68
+ *
69
+ * The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
70
+ * { type: "ask_request", requestId, questions } as a JSON line
71
+ * and waits for:
72
+ * { type: "ask_response", requestId, cancelled, results } as a JSON line
73
+ *
74
+ * We forward the request onto the bus (ASK_REQUEST), the ask package shows the
75
+ * parent TUI dialog, then emits ASK_RESPONSE on the bus, and we write it back
76
+ * to the socket connection.
77
+ *
78
+ * Returns the socket path and a cleanup function.
55
79
  */
56
- function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
57
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
58
- const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
59
- tempDirs.add(tmpDir);
60
- const filePath = join(tmpDir, `prompt-${safeName}.md`);
61
- writeFileSync(filePath, prompt, { encoding: "utf-8" });
62
- return { dir: tmpDir, filePath };
63
- }
64
-
65
- /**
66
- * Clean up temp prompt file and directory.
67
- */
68
- function cleanupTempFiles(dir: string | null, filePath: string | null): void {
69
- if (filePath) {
70
- try { unlinkSync(filePath); } catch { /* ignore */ }
71
- }
72
- if (dir) {
73
- try {
74
- rmdirSync(dir);
75
- tempDirs.delete(dir);
76
- } catch { /* leave in Set so exit handler can retry with rmSync */ }
77
- }
78
- }
80
+ function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
81
+ // Keep the socket path short — Linux limit is 108 chars.
82
+ const socketPath = path.join(os.tmpdir(), `pi-ask-${randomUUID().slice(0, 8)}.sock`);
83
+
84
+ // Map of pending ask requests: requestId → write-back callback
85
+ const pending = new Map<string, (response: unknown) => void>();
86
+
87
+ // Listen for ASK_RESPONSE from the bus and route back to the waiting socket conn
88
+ const unsubResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
89
+ const data = payload as {
90
+ requestId: string;
91
+ cancelled: boolean;
92
+ results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
93
+ };
94
+ const send = pending.get(data.requestId);
95
+ if (send) {
96
+ pending.delete(data.requestId);
97
+ send({
98
+ type: "ask_response",
99
+ requestId: data.requestId,
100
+ cancelled: data.cancelled,
101
+ results: data.results,
102
+ });
103
+ }
104
+ });
79
105
 
80
- /**
81
- * Spawn a child `pi` process in JSON mode.
82
- */
83
- export function spawnSubagent(options: SpawnOptions): ChildProcess {
84
- const { command, args: baseArgs } = resolvePiCommand();
85
- const args: string[] = [
86
- ...baseArgs,
87
- "--mode", "json",
88
- "-p",
89
- "--no-session",
90
- ];
91
-
92
- // Create IPC socket for ask tool responses.
93
- // On Unix: filesystem Unix domain socket. On Windows: named pipe (\\.\pipe\).
94
- const socketName = `pi-subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
95
- const socketPath = process.platform === "win32"
96
- ? `\\\\.\\pipe\\${socketName}`
97
- : join(tmpdir(), socketName);
98
- // Only Unix socket files need pre-unlinking; Windows named pipes auto-clean.
99
- if (process.platform !== "win32") {
100
- try { unlinkSync(socketPath); } catch { /* doesn't exist yet */ }
101
- }
102
- let clientSocket: Socket | undefined;
103
- const pendingAsks = new Map<string, Socket>();
104
- const server = createServer((socket: Socket) => {
105
- clientSocket = socket;
106
- (child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = socket;
107
- // Parse incoming lines from the child's ask tool (ask_request) and forward to bus
106
+ const server = net.createServer((socket) => {
108
107
  let buffer = "";
109
- socket.on("data", (data: Buffer) => {
110
- buffer += data.toString();
108
+
109
+ socket.on("data", (chunk: Buffer) => {
110
+ buffer += chunk.toString("utf-8");
111
111
  const lines = buffer.split("\n");
112
112
  buffer = lines.pop() ?? "";
113
+
113
114
  for (const line of lines) {
115
+ const trimmed = line.trim();
116
+ if (!trimmed) continue;
114
117
  try {
115
- const msg = JSON.parse(line);
116
- if (msg.type === "ask_request" && msg.requestId) {
117
- pendingAsks.set(msg.requestId, socket);
118
+ const msg = JSON.parse(trimmed) as {
119
+ type: string;
120
+ requestId: string;
121
+ questions: unknown[];
122
+ };
123
+ if (msg.type === "ask_request") {
124
+ // Register write-back so ASK_RESPONSE handler can find this socket
125
+ pending.set(msg.requestId, (response) => {
126
+ try {
127
+ socket.write(JSON.stringify(response) + "\n");
128
+ } catch {
129
+ // socket already closed
130
+ }
131
+ });
132
+ // Forward to bus — ask package will show the TUI dialog
118
133
  getBus().emit(Events.ASK_REQUEST, {
119
- source: `subagent:${options.agent?.name ?? "general"}`,
134
+ source: `subagent:${agentName}`,
120
135
  requestId: msg.requestId,
121
136
  questions: msg.questions,
122
137
  });
123
138
  }
124
- } catch { /* ignore non-JSON */ }
125
- }
126
- });
127
- socket.on("close", () => {
128
- clientSocket = undefined;
129
- (child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = undefined;
130
- // Clean up any pending asks tied to this socket
131
- for (const [id, s] of pendingAsks) {
132
- if (s === socket) pendingAsks.delete(id);
139
+ } catch {
140
+ // malformed JSON — ignore
141
+ }
133
142
  }
134
143
  });
144
+
145
+ socket.on("error", () => { /* connection dropped */ });
135
146
  });
136
- server.listen(socketPath); // async, non-blocking
137
-
138
- // Write ask responses back to the child over the originating socket
139
- const unsubAskResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
140
- const data = payload as { requestId: string; cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> };
141
- const socket = pendingAsks.get(data.requestId);
142
- if (socket && socket.writable) {
143
- pendingAsks.delete(data.requestId);
144
- socket.write(JSON.stringify({
145
- type: "ask_response",
146
- requestId: data.requestId,
147
- cancelled: data.cancelled,
148
- results: data.results,
149
- }) + "\n");
150
- }
151
- });
152
- server.on("close", () => {
153
- if (process.platform !== "win32") {
154
- try { unlinkSync(socketPath); } catch { /* ignore */ }
155
- }
156
- });
157
147
 
158
- // Resolve model with correct priority:
159
- // 1. frontmatter model (agent.model)
160
- // 2. explicit tool-call model (options.model)
161
- // 3. currently active model (options.activeModel)
162
- // 4. no --model flag → pi default
163
- const modelToUse = options.agent?.model ?? options.model ?? options.activeModel;
164
- if (modelToUse) {
165
- args.push("--model", modelToUse);
148
+ server.listen(socketPath);
149
+
150
+ const cleanup = () => {
151
+ unsubResponse();
152
+ server.close();
153
+ try { fs.unlinkSync(socketPath); } catch { /* already gone */ }
154
+ };
155
+
156
+ return { socketPath, cleanup };
157
+ }
158
+
159
+ /**
160
+ * Spawn a subagent as a fresh `pi --mode json --no-session -p <task>` process.
161
+ *
162
+ * A Unix socket server is started in the parent to bridge the child's ask tool
163
+ * back to the parent's TUI dialog. The socket path is passed via PI_SUBAGENT_SOCKET.
164
+ */
165
+ export function spawnSubagent(options: SpawnOptions): ChildProcess {
166
+ const piBinary = resolvePiBinary();
167
+ const agentName = options.agent?.name ?? "general";
168
+
169
+ // Start ask bridge socket before spawning so the env var is ready
170
+ const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
171
+
172
+ // Build CLI args
173
+ const args: string[] = ["--mode", "json", "--no-session", "-p"];
174
+
175
+ // Model: agent.model > options.model > options.activeModel
176
+ const model = options.agent?.model ?? options.model ?? options.activeModel;
177
+ if (model) {
178
+ args.push("--model", model);
166
179
  }
167
180
 
168
- // Apply other agent config options
169
- if (options.agent) {
170
- const agent = options.agent;
171
- if (agent.thinking) {
172
- args.push("--thinking", agent.thinking);
173
- }
174
- if (agent.tools && agent.tools.length > 0) {
175
- args.push("--tools", agent.tools.join(","));
176
- }
181
+ // Thinking level from agent config
182
+ if (options.agent?.thinking) {
183
+ args.push("--thinking", options.agent.thinking);
184
+ }
185
+
186
+ // Tool allowlist from agent config
187
+ if (options.agent?.tools && options.agent.tools.length > 0) {
188
+ args.push("--tools", options.agent.tools.join(","));
177
189
  }
178
190
 
179
- // Temp files for system prompt
180
- let tmpDir: string | null = null;
181
- let tmpPath: string | null = null;
191
+ // Always exclude the subagent tool itself to prevent infinite recursion
192
+ args.push("--exclude-tools", "subagent");
182
193
 
183
- if (options.agent && options.agent.systemPrompt.trim()) {
184
- const tmp = writePromptToFile(options.agent.name, options.agent.systemPrompt);
185
- tmpDir = tmp.dir;
186
- tmpPath = tmp.filePath;
187
- args.push("--append-system-prompt", tmpPath);
194
+ // Agent system prompt
195
+ const systemPrompt = options.agent?.systemPrompt?.trim();
196
+ if (systemPrompt) {
197
+ args.push("--system-prompt", systemPrompt);
188
198
  }
189
199
 
200
+ // The task is the final positional argument
190
201
  args.push(options.task);
191
202
 
192
- const child = spawn(command, args, {
203
+ const child = spawn(piBinary, args, {
193
204
  cwd: options.cwd || process.cwd(),
194
- stdio: ["ignore", "pipe", "pipe"],
195
205
  env: {
196
206
  ...process.env,
197
207
  PI_SUBAGENT_SOCKET: socketPath,
198
208
  },
209
+ stdio: ["ignore", "pipe", "pipe"],
199
210
  });
200
211
 
212
+ // Clean up socket server when child exits
213
+ child.on("exit", cleanupSocket);
214
+ child.on("error", cleanupSocket);
215
+
201
216
  // Handle abort signal
202
- let abortHandler: (() => void) | undefined;
203
217
  if (options.signal) {
204
- abortHandler = () => {
218
+ const abortHandler = () => {
205
219
  if (child.pid && !child.killed) {
206
220
  child.kill("SIGTERM");
207
221
  const forceKill = setTimeout(() => {
208
- if (!child.killed) {
209
- child.kill("SIGKILL");
210
- }
222
+ if (!child.killed) child.kill("SIGKILL");
211
223
  }, 3000);
212
224
  forceKill.unref();
213
225
  }
214
226
  };
215
227
  options.signal.addEventListener("abort", abortHandler, { once: true });
228
+ child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
216
229
  }
217
230
 
218
- // Clean up temp files and listeners on exit
219
- const exitCleanup = (): void => {
220
- child.removeListener("exit", exitCleanup);
221
- child.removeListener("error", exitCleanup);
222
- if (abortHandler && options.signal) {
223
- options.signal.removeEventListener("abort", abortHandler);
224
- }
225
- server.close();
226
- unsubAskResponse();
227
- if (clientSocket) clientSocket.destroy();
228
- cleanupTempFiles(tmpDir, tmpPath);
229
- };
230
- child.on("exit", exitCleanup);
231
- child.on("error", exitCleanup);
232
-
233
- // Attach client socket reference to child for streamEvents
234
- (child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = clientSocket;
235
231
  return child;
236
232
  }
package/src/stream.ts CHANGED
@@ -18,7 +18,11 @@ export interface StreamCallbacks {
18
18
  }
19
19
 
20
20
  /**
21
- * Stream JSON events from a child pi process and build progress/result.
21
+ * Stream JSON events from a child `pi --mode json` process and build progress/result.
22
+ *
23
+ * The child writes one JSON object per line to stdout. We read each line via
24
+ * readline, parse it, and dispatch to the same handler functions used previously.
25
+ * stderr is drained silently (captured as the error string on non-zero exit).
22
26
  */
23
27
  export function streamEvents(
24
28
  child: ChildProcess,
@@ -27,20 +31,15 @@ export function streamEvents(
27
31
  return new Promise((resolve, reject) => {
28
32
  const startTime = Date.now();
29
33
 
30
- // Startup safeguard: if the child produces no JSON event within
31
- // STARTUP_TIMEOUT_MS, kill it. This guards against hangs during pi
32
- // initialization (model never loads, auth fails, etc.) and is the only
33
- // automatic timeout. Once any event arrives the model is considered
34
- // active and runtime is controlled entirely by the user's abort
35
- // signal — a model that is REALLY thinking is left alone until the
36
- // user explicitly cancels.
34
+ // Startup safeguard: if no JSON event arrives within 2 minutes, kill the child.
37
35
  const STARTUP_TIMEOUT_MS = 2 * 60 * 1000;
38
-
39
36
  let startupTimer: NodeJS.Timeout | undefined = setTimeout(() => {
40
37
  child.kill("SIGKILL");
41
- reject(new Error(
42
- `subagent timed out: no model output within ${STARTUP_TIMEOUT_MS / 60_000} minutes of startup`,
43
- ));
38
+ reject(
39
+ new Error(
40
+ `subagent timed out: no output within ${STARTUP_TIMEOUT_MS / 60_000} minutes of startup`,
41
+ ),
42
+ );
44
43
  }, STARTUP_TIMEOUT_MS);
45
44
 
46
45
  const clearStartupTimer = (): void => {
@@ -67,9 +66,13 @@ export function streamEvents(
67
66
  toolCalls: [],
68
67
  finalOutput: undefined,
69
68
  };
69
+
70
+ // Collect stderr for error reporting
71
+ const stderrChunks: Buffer[] = [];
72
+ child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
73
+
70
74
  let error: string | undefined;
71
75
 
72
- // Build progress from state
73
76
  const buildProgress = (): SubagentProgress => ({
74
77
  agent: callbacks.agent ?? "subagent",
75
78
  status: "running",
@@ -84,48 +87,50 @@ export function streamEvents(
84
87
  cost: state.totalCost,
85
88
  durationMs: Date.now() - startTime,
86
89
  error,
87
- output: state.accumulatedOutput.length > 0 ? state.accumulatedOutput.join("\n\n") : undefined,
90
+ output:
91
+ state.accumulatedOutput.length > 0
92
+ ? state.accumulatedOutput.join("\n\n")
93
+ : undefined,
88
94
  recentOutput: state.recentOutput.length > 0 ? state.recentOutput : undefined,
89
95
  toolCalls: state.toolCalls.length > 0 ? state.toolCalls : undefined,
90
96
  model: state.model,
91
97
  });
92
98
 
93
- const emitProgress = () => {
94
- callbacks.onProgress?.(buildProgress());
95
- };
99
+ const emitProgress = () => callbacks.onProgress?.(buildProgress());
96
100
 
97
- // Periodic progress updates for live duration display
101
+ // Periodic heartbeat for live duration display
98
102
  const heartbeat = setInterval(emitProgress, 1000);
99
103
 
100
- // Collect stderr
101
- const stderrParts: string[] = [];
102
- child.stderr?.on("data", (data: Buffer) => {
103
- stderrParts.push(data.toString());
104
- });
104
+ // Read stdout as newline-delimited JSON
105
+ if (!child.stdout) {
106
+ clearStartupTimer();
107
+ clearInterval(heartbeat);
108
+ reject(new Error("subagent child has no stdout pipe"));
109
+ return;
110
+ }
105
111
 
106
- // Parse JSON lines from stdout
107
- const rl = createInterface({
108
- input: child.stdout!,
109
- crlfDelay: Infinity,
110
- });
112
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
113
+
114
+ rl.on("line", (line) => {
115
+ const trimmed = line.trim();
116
+ if (!trimmed) return;
111
117
 
112
- rl.on("line", (line: string) => {
113
118
  let event: JsonEvent;
114
119
  try {
115
- event = JSON.parse(line);
120
+ event = JSON.parse(trimmed) as JsonEvent;
116
121
  } catch {
117
- return; // Skip non-JSON lines
122
+ // Non-JSON output — ignore (can happen from pi startup messages)
123
+ return;
118
124
  }
119
125
 
120
- // First event received from the child means the model has engaged —
121
- // from here on, the user controls lifetime via the abort signal.
126
+ // First real event means model has engaged — cancel startup watchdog
122
127
  clearStartupTimer();
123
128
 
124
129
  switch (event.type) {
125
130
  case "tool_execution_start": {
126
131
  handleToolStart(state, event);
127
132
  emitProgress();
128
- // Forward manage_todo_list writes to the bus for parent widget
133
+ // Forward manage_todo_list writes to the parent's todo widget
129
134
  if (event.toolName === "manage_todo_list") {
130
135
  const args = event.args as Record<string, unknown> | undefined;
131
136
  const todoList = args?.todoList as Array<unknown> | undefined;
@@ -141,17 +146,14 @@ export function streamEvents(
141
146
  case "tool_execution_end": {
142
147
  handleToolEnd(state);
143
148
  emitProgress();
149
+ handleToolResult(state, event);
150
+ emitProgress();
144
151
  break;
145
152
  }
146
153
  case "turn_start": {
147
154
  state.turnCount++;
148
155
  break;
149
156
  }
150
- case "tool_result_end": {
151
- handleToolResult(state, event);
152
- emitProgress();
153
- break;
154
- }
155
157
  case "message_end": {
156
158
  handleMessageEnd(state, event);
157
159
  emitProgress();
@@ -161,6 +163,7 @@ export function streamEvents(
161
163
  handleAgentEnd(state, event);
162
164
  break;
163
165
  }
166
+ // Ignore: session, agent_start, message_start, message_update, turn_end, tool_execution_update
164
167
  }
165
168
  });
166
169
 
@@ -171,8 +174,10 @@ export function streamEvents(
171
174
  const durationMs = Date.now() - startTime;
172
175
  const exitCode = code ?? 1;
173
176
 
174
- if (stderrParts.length > 0 && exitCode !== 0) {
175
- error = stderrParts.join("").trim();
177
+ // Surface stderr as error if the process failed and we have no other error
178
+ if (exitCode !== 0 && !error) {
179
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
180
+ if (stderr) error = stderr;
176
181
  }
177
182
 
178
183
  const result: SubagentResult = {
@@ -202,10 +207,9 @@ export function streamEvents(
202
207
  },
203
208
  };
204
209
 
205
- // Final progress update
206
210
  callbacks.onProgress?.(result.progress!);
207
211
 
208
- // Clear subagent todos from the bus on process exit
212
+ // Clear subagent todos from the bus on exit
209
213
  getBus().emit(Events.TODOS_CLEAR, {
210
214
  source: `subagent:${callbacks.agent ?? "general"}`,
211
215
  });