@harms-haus/pi-subagents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Subagent Settings
3
+ *
4
+ * Reads maxLinesPerWindow and commandPreviewWidth from global and project-local
5
+ * settings files. Project-local settings override global settings.
6
+ *
7
+ * Settings file locations:
8
+ * Global: ~/.pi/agent/settings.json
9
+ * Project: .pi/settings.json
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // ── Settings Types ───────────────────────────────────────────────────
17
+
18
+ export interface SubagentSettings {
19
+ maxLinesPerWindow?: number;
20
+ commandPreviewWidth?: number;
21
+ extend_timeout_debounce?: number;
22
+ looping_tool_count?: number;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export interface SettingsFile {
27
+ subagents?: SubagentSettings;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ // ── Settings File Paths ──────────────────────────────────────────────
32
+
33
+ export function getGlobalSettingsPath(): string {
34
+ const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
35
+ return join(agentDir, "settings.json");
36
+ }
37
+
38
+ export function getProjectSettingsPath(cwd: string): string {
39
+ return join(cwd, ".pi", "settings.json");
40
+ }
41
+
42
+ // ── Settings File Reading ────────────────────────────────────────────
43
+
44
+ export async function readSettingsFile(filePath: string): Promise<SettingsFile> {
45
+ try {
46
+ const content = await readFile(filePath, "utf8");
47
+ return JSON.parse(content) as SettingsFile;
48
+ } catch (error) {
49
+ const isEnoent =
50
+ error instanceof Error &&
51
+ "code" in error &&
52
+ (error as NodeJS.ErrnoException).code === "ENOENT";
53
+ if (!isEnoent) {
54
+ console.warn(
55
+ `Failed to read settings file ${filePath}:`,
56
+ error instanceof Error ? error.message : error,
57
+ );
58
+ }
59
+ return {};
60
+ }
61
+ }
62
+
63
+ // ── Private Settings Helper ─────────────────────────────────────────
64
+
65
+ async function loadSetting(
66
+ key: string,
67
+ defaultValue: number,
68
+ cwd?: string,
69
+ options?: { clamp?: [number, number] },
70
+ ): Promise<number> {
71
+ const globalSettings = await readSettingsFile(getGlobalSettingsPath());
72
+ const globalSubagents = globalSettings.subagents ?? {};
73
+ let value: unknown = (globalSubagents as Record<string, unknown>)[key] ?? defaultValue;
74
+
75
+ if (cwd) {
76
+ const projectSettings = await readSettingsFile(getProjectSettingsPath(cwd));
77
+ const projectSubagents = projectSettings.subagents ?? {};
78
+ const projectValue = (projectSubagents as Record<string, unknown>)[key];
79
+ if (projectValue !== undefined) {
80
+ value = projectValue;
81
+ }
82
+ }
83
+
84
+ if (typeof value !== "number" || !Number.isFinite(value)) return defaultValue;
85
+ if (options?.clamp) {
86
+ return Math.max(options.clamp[0], Math.min(options.clamp[1], value));
87
+ }
88
+ return value;
89
+ }
90
+
91
+ // ── Exported Settings Loaders ────────────────────────────────────────
92
+
93
+ /**
94
+ * Load maxLinesPerWindow from settings files.
95
+ * Project-local settings override global settings. Defaults to 15.
96
+ */
97
+ export async function loadMaxLinesPerWindow(cwd?: string): Promise<number> {
98
+ return loadSetting("maxLinesPerWindow", 15, cwd);
99
+ }
100
+
101
+ /**
102
+ * Load commandPreviewWidth from settings files, with TTY fallback.
103
+ *
104
+ * Priority order:
105
+ * 1. Explicitly configured setting (project overrides global)
106
+ * 2. TTY-derived width: Math.max(process.stdout.columns - 4, 20)
107
+ * 3. Default: 160
108
+ * Result is clamped to a minimum of 20.
109
+ */
110
+ export async function loadCommandPreviewWidth(cwd?: string): Promise<number> {
111
+ // Read settings files to check for an explicitly configured value
112
+ const globalSettings = await readSettingsFile(getGlobalSettingsPath());
113
+ const globalSubagents = globalSettings.subagents ?? {};
114
+ let configuredValue: unknown = (globalSubagents as Record<string, unknown>)[
115
+ "commandPreviewWidth"
116
+ ];
117
+
118
+ if (cwd) {
119
+ const projectSettings = await readSettingsFile(getProjectSettingsPath(cwd));
120
+ const projectSubagents = projectSettings.subagents ?? {};
121
+ const projectValue = (projectSubagents as Record<string, unknown>)["commandPreviewWidth"];
122
+ if (projectValue !== undefined) {
123
+ configuredValue = projectValue;
124
+ }
125
+ }
126
+
127
+ // If the user explicitly configured a value, use it (clamped)
128
+ if (typeof configuredValue === "number" && Number.isFinite(configuredValue)) {
129
+ return Math.max(configuredValue, 20);
130
+ }
131
+
132
+ // No explicit setting: fall back to TTY width if available
133
+ if (typeof process.stdout.columns === "number") {
134
+ return Math.max(process.stdout.columns - 4, 20);
135
+ }
136
+
137
+ // No TTY and no setting: use default
138
+ return 160;
139
+ }
140
+
141
+ /**
142
+ * Load extend_timeout_debounce from settings files.
143
+ * Project-local settings override global settings. Defaults to 30.
144
+ */
145
+ export async function loadExtendTimeoutDebounce(cwd?: string): Promise<number> {
146
+ return loadSetting("extend_timeout_debounce", 30, cwd, { clamp: [0, 300] });
147
+ }
148
+
149
+ /**
150
+ * Load looping_tool_count from settings files.
151
+ * Project-local settings override global settings. Defaults to 5.
152
+ */
153
+ export async function loadLoopingToolCount(cwd?: string): Promise<number> {
154
+ return loadSetting("looping_tool_count", 5, cwd, { clamp: [0, 50] });
155
+ }
@@ -0,0 +1,30 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4
+ import { TtlCache } from "./cache";
5
+
6
+ const skillCache = new TtlCache<string[]>(5000);
7
+
8
+ export function invalidatePackageSkillCache(): void {
9
+ skillCache.invalidate();
10
+ }
11
+
12
+ export async function resolvePackageSkillPaths(cwd: string, agentDir?: string): Promise<string[]> {
13
+ const cached = skillCache.get(cwd);
14
+ if (cached) {
15
+ return cached;
16
+ }
17
+
18
+ const resolvedAgentDir = agentDir ?? join(homedir(), ".pi", "agent");
19
+ const settingsManager = SettingsManager.create(cwd, resolvedAgentDir);
20
+ const packageManager = new DefaultPackageManager({
21
+ cwd,
22
+ agentDir: resolvedAgentDir,
23
+ settingsManager,
24
+ });
25
+ const resolvedPaths = await packageManager.resolve();
26
+ const paths = resolvedPaths.skills.filter((s) => s.enabled).map((s) => s.path);
27
+
28
+ skillCache.set(cwd, paths);
29
+ return paths;
30
+ }
package/src/spawner.ts ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Sub-Agent Spawner
3
+ *
4
+ * Handles spawning child processes for sub-agents, managing their stdout/stderr,
5
+ * and processing JSON events from the pi binary.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { resolve } from "node:path";
10
+ import { formatToolCall, formatToolResultInline } from "./format-tool-call";
11
+ import { profileToArgs } from "./profiles";
12
+ import { loadCommandPreviewWidth } from "./settings";
13
+ import { MAX_MESSAGES_PER_SESSION, syncState } from "./types";
14
+ import { appendLineToWindow, getTextParts } from "./utils";
15
+ import type { SubagentProfile } from "./profiles";
16
+ import type { SubAgentTask, SubAgentWindow, SubagentSessionData, ToolCallPart } from "./types";
17
+ import type { Message } from "@earendil-works/pi-ai";
18
+
19
+ // ── Helpers ───────────────────────────────────────────────────────────
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────
22
+
23
+ export interface RunSubAgentOptions {
24
+ task: SubAgentTask;
25
+ win: SubAgentWindow;
26
+ maxLines: number;
27
+ signal?: AbortSignal;
28
+ onUpdate: () => void;
29
+ session: SubagentSessionData;
30
+ profile?: SubagentProfile;
31
+ loopingToolCount?: number;
32
+ agentDir?: string;
33
+ }
34
+
35
+ // ── Helpers ───────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Determine how to invoke the pi binary.
39
+ * Returns the command and arguments needed to spawn a sub-agent process.
40
+ */
41
+ function getPiInvocation(): { command: string; args: string[] } {
42
+ const currentScript = process.argv[1];
43
+ if (currentScript && !currentScript.startsWith("/$bunfs/root/")) {
44
+ return { command: process.execPath, args: [currentScript] };
45
+ }
46
+ return { command: "pi", args: [] };
47
+ }
48
+
49
+ /**
50
+ * Validate and resolve the cwd parameter for a sub-agent task.
51
+ */
52
+ function validateCwd(cwd: string | undefined, fallback: string): string {
53
+ const target = cwd ? resolve(cwd) : fallback;
54
+ if (!target.startsWith("/")) {
55
+ throw new Error("cwd must be an absolute path");
56
+ }
57
+ if (target.includes("..")) {
58
+ throw new Error("cwd must not contain '..' path segments");
59
+ }
60
+ return target;
61
+ }
62
+
63
+ // ── Main Function ────────────────────────────────────────────────────
64
+
65
+ // ── Stdout line processing helpers ─────────────────────────────────────
66
+
67
+ /** Context object passed to all stdout processing helpers. */
68
+ interface LineContext {
69
+ win: SubAgentWindow;
70
+ maxLines: number;
71
+ session: SubagentSessionData;
72
+ onUpdate: () => void;
73
+ cwd: string;
74
+ widthBudget: number;
75
+ loopingToolCount: number;
76
+ }
77
+
78
+ /**
79
+ * Process a turn_end event to inline ls/find result summaries.
80
+ */
81
+ function handleTurnEnd(event: { type?: string }, ctx: LineContext): void {
82
+ const turnEvent = event as {
83
+ toolResults?: Array<{
84
+ toolName: string;
85
+ content: Array<{ type: string; text?: string }>;
86
+ details?: Record<string, unknown>;
87
+ isError: boolean;
88
+ }>;
89
+ };
90
+ const toolResults = turnEvent.toolResults;
91
+ if (!Array.isArray(toolResults)) {
92
+ return;
93
+ }
94
+
95
+ const usedIndices = new Set<number>();
96
+ for (const result of toolResults) {
97
+ if (result.isError || (result.toolName !== "ls" && result.toolName !== "find")) {
98
+ continue;
99
+ }
100
+ inlineToolResultSummary(result, ctx, usedIndices);
101
+ }
102
+ ctx.onUpdate();
103
+ }
104
+
105
+ /**
106
+ * Inline a single ls/find tool result summary into the window.
107
+ */
108
+ function inlineToolResultSummary(
109
+ result: {
110
+ toolName: string;
111
+ content: Array<{ type: string; text?: string }>;
112
+ details?: Record<string, unknown>;
113
+ },
114
+ ctx: LineContext,
115
+ usedIndices: Set<number>,
116
+ ): void {
117
+ const textParts: string[] = [];
118
+ for (const part of result.content) {
119
+ if (part.type === "text" && part.text) {
120
+ textParts.push(part.text);
121
+ }
122
+ }
123
+ const textContent = textParts.join("");
124
+ if (!textContent) {
125
+ return;
126
+ }
127
+
128
+ const inlineSummary = formatToolResultInline(result.toolName, textContent, result.details);
129
+ if (!inlineSummary) {
130
+ return;
131
+ }
132
+
133
+ const marker = `→ ${result.toolName} →`;
134
+ // Find the first unused tool line matching this tool name
135
+ // that has NOT already received an inline result from a previous turn.
136
+ // A bare tool call line ("→ ls → src") has no " → " after the path,
137
+ // while an already-inlined line ("→ ls → src → 2 files") does.
138
+ for (let i = 0; i < ctx.win.lines.length; i++) {
139
+ const line = ctx.win.lines[i];
140
+ if (!line) continue;
141
+ if (
142
+ !usedIndices.has(i) &&
143
+ line.kind === "tool" &&
144
+ line.text.includes(marker) &&
145
+ !line.text.slice(line.text.indexOf(marker) + marker.length).includes(" → ")
146
+ ) {
147
+ usedIndices.add(i);
148
+ // Mutate the text directly — same object ref is in allMessages
149
+ line.text = `${line.text} → ${inlineSummary}`;
150
+ return;
151
+ }
152
+ }
153
+ appendLineToWindow(ctx.win, ` ${result.toolName}: ${inlineSummary}`, ctx.maxLines, "tool");
154
+ }
155
+
156
+ /**
157
+ * Track todo progress from write_todos / edit_todos tool calls.
158
+ */
159
+ function trackTodoProgress(
160
+ toolName: string,
161
+ toolArgs: Record<string, unknown>,
162
+ win: SubAgentWindow,
163
+ ): void {
164
+ if (toolName === "write_todos") {
165
+ const newCount = (toolArgs.todos as unknown[] | undefined)?.length ?? 0;
166
+ const mode = toolArgs.mode as string | undefined;
167
+ if (mode === "append") {
168
+ win.todoTotal = (win.todoTotal ?? 0) + newCount;
169
+ } else {
170
+ win.todoTotal = newCount;
171
+ win.todoCompleted = 0;
172
+ }
173
+ } else if (toolName === "edit_todos") {
174
+ const editAction = toolArgs.action as string | undefined;
175
+ const editIndices = toolArgs.indices as number[] | undefined;
176
+ if ((editAction === "complete" || editAction === "abandon") && editIndices) {
177
+ win.todoCompleted = (win.todoCompleted ?? 0) + editIndices.length;
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Track a tool call signature for loop detection, capping the buffer.
184
+ */
185
+ function trackToolCallForLoop(
186
+ toolName: string,
187
+ toolArgs: Record<string, unknown>,
188
+ win: SubAgentWindow,
189
+ loopingToolCount: number,
190
+ ): void {
191
+ const signature = `${toolName}:${JSON.stringify(toolArgs, Object.keys(toolArgs).sort())}`;
192
+
193
+ if (!win.recentToolCalls) {
194
+ win.recentToolCalls = [];
195
+ }
196
+ win.recentToolCalls.push(signature);
197
+ // Cap to only what's needed for loop detection
198
+ const maxKept = Math.max(loopingToolCount * 2, 20);
199
+ if (win.recentToolCalls.length > maxKept) {
200
+ win.recentToolCalls = win.recentToolCalls.slice(-maxKept);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Process a single tool call from a message: format, track todos, and record for loop detection.
206
+ */
207
+ function processToolCall(part: ToolCallPart, ctx: LineContext): void {
208
+ const toolArgs = part.arguments || {};
209
+ const toolName = part.name;
210
+ const preview = formatToolCall(toolName, toolArgs, ctx.cwd, ctx.widthBudget);
211
+ appendLineToWindow(ctx.win, `→ ${preview}`, ctx.maxLines, "tool");
212
+
213
+ trackTodoProgress(toolName, toolArgs, ctx.win);
214
+ trackToolCallForLoop(toolName, toolArgs, ctx.win, ctx.loopingToolCount);
215
+ }
216
+
217
+ /**
218
+ * Render text parts from a message into the window.
219
+ */
220
+ function processTextParts(textParts: string[], ctx: LineContext): void {
221
+ for (const text of textParts) {
222
+ for (const textLine of text.split("\n")) {
223
+ appendLineToWindow(ctx.win, textLine, ctx.maxLines);
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Sync assistant metadata (model, stopReason, errorMessage) from message to window/session.
230
+ */
231
+ function syncAssistantMeta(msg: Message, win: SubAgentWindow, session: SubagentSessionData): void {
232
+ if (msg.role !== "assistant") {
233
+ return;
234
+ }
235
+ win.model = msg.model;
236
+ win.stopReason = msg.stopReason;
237
+ win.errorMessage = msg.errorMessage;
238
+ syncState(win, session);
239
+ }
240
+
241
+ /**
242
+ * Check whether the last N tool calls are all identical (loop detection).
243
+ */
244
+ function checkLoop(win: SubAgentWindow, loopingToolCount: number): boolean {
245
+ if (
246
+ loopingToolCount <= 0 ||
247
+ !win.recentToolCalls ||
248
+ win.recentToolCalls.length < loopingToolCount
249
+ ) {
250
+ return false;
251
+ }
252
+ const recent = win.recentToolCalls.slice(-loopingToolCount);
253
+ for (let i = 1; i < recent.length; i++) {
254
+ if (recent[0] !== recent[i]) {
255
+ return false;
256
+ }
257
+ }
258
+ return true;
259
+ }
260
+
261
+ /**
262
+ * Process a message_end event: store the message, render content, and detect loops.
263
+ */
264
+ function handleMessageEnd(msg: Message, ctx: LineContext): { loopDetected: boolean } {
265
+ ctx.session.messages.push(msg);
266
+ if (ctx.session.messages.length >= MAX_MESSAGES_PER_SESSION) {
267
+ ctx.session.messages.shift();
268
+ }
269
+
270
+ const textParts = getTextParts(msg);
271
+ const hasContent = textParts.length > 0 || (msg.role === "assistant" && msg.content);
272
+
273
+ if (hasContent) {
274
+ processTextParts(textParts, ctx);
275
+
276
+ if (msg.content && typeof msg.content !== "string") {
277
+ for (const part of msg.content) {
278
+ if (part.type === "toolCall") {
279
+ processToolCall(part, ctx);
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ syncAssistantMeta(msg, ctx.win, ctx.session);
286
+ ctx.onUpdate();
287
+
288
+ return { loopDetected: checkLoop(ctx.win, ctx.loopingToolCount) };
289
+ }
290
+
291
+ /**
292
+ * Process a single line of stdout output from the sub-agent process.
293
+ * Handles both plain text lines and JSON events.
294
+ */
295
+ function handleStdoutLine(
296
+ line: string,
297
+ win: SubAgentWindow,
298
+ maxLines: number,
299
+ session: SubagentSessionData,
300
+ onUpdate: () => void,
301
+ cwd: string,
302
+ widthBudget: number,
303
+ loopingToolCount: number,
304
+ ): { loopDetected: boolean } {
305
+ if (!line.trim()) {
306
+ return { loopDetected: false };
307
+ }
308
+
309
+ let event: { type?: string; message?: Message };
310
+ try {
311
+ event = JSON.parse(line) as { type?: string; message?: Message };
312
+ } catch {
313
+ appendLineToWindow(win, line, maxLines);
314
+ onUpdate();
315
+ return { loopDetected: false };
316
+ }
317
+
318
+ const ctx: LineContext = { win, maxLines, session, onUpdate, cwd, widthBudget, loopingToolCount };
319
+
320
+ if (event.type === "turn_end") {
321
+ handleTurnEnd(event, ctx);
322
+ return { loopDetected: false };
323
+ }
324
+
325
+ if (event.type === "message_end" && event.message) {
326
+ return handleMessageEnd(event.message, ctx);
327
+ }
328
+
329
+ return { loopDetected: false };
330
+ }
331
+
332
+ /**
333
+ * Process stderr data from the sub-agent process.
334
+ */
335
+ function handleStderrData(
336
+ data: Buffer,
337
+ win: SubAgentWindow,
338
+ maxLines: number,
339
+ onUpdate: () => void,
340
+ ): void {
341
+ const text = data.toString().trim();
342
+ if (!text) {
343
+ return;
344
+ }
345
+
346
+ appendLineToWindow(win, `[stderr]: ${text}`, maxLines);
347
+ onUpdate();
348
+ }
349
+
350
+ /**
351
+ * Handle the sub-agent process exit.
352
+ */
353
+ function handleProcessExit(
354
+ code: number | null,
355
+ win: SubAgentWindow,
356
+ session: SubagentSessionData,
357
+ buffer: string,
358
+ bufferTimeout: ReturnType<typeof setTimeout> | null,
359
+ processLineFn: (line: string) => void,
360
+ onUpdate: () => void,
361
+ ): void {
362
+ if (buffer.trim()) {
363
+ processLineFn(buffer);
364
+ }
365
+ if (bufferTimeout) {
366
+ clearTimeout(bufferTimeout);
367
+ }
368
+
369
+ const exitCode = code ?? 0;
370
+ win.exitCode = exitCode;
371
+
372
+ const isError = code !== 0 || win.stopReason === "error" || win.stopReason === "aborted";
373
+ const status = isError ? "error" : "completed";
374
+
375
+ win.status = status;
376
+ win.completedAt = Date.now();
377
+ syncState(win, session);
378
+
379
+ onUpdate();
380
+ }
381
+
382
+ /**
383
+ * Set up abort signal handler with SIGTERM/SIGKILL escalation.
384
+ */
385
+ function setupAbortHandler(proc: ReturnType<typeof spawn>, signal: AbortSignal): void {
386
+ const killProc = () => {
387
+ proc.kill("SIGTERM");
388
+ setTimeout(() => {
389
+ if (!proc.killed) {
390
+ proc.kill("SIGKILL");
391
+ }
392
+ }, 5000);
393
+ };
394
+
395
+ if (signal.aborted) {
396
+ killProc();
397
+ return;
398
+ }
399
+
400
+ signal.addEventListener("abort", killProc, { once: true });
401
+ }
402
+
403
+ /**
404
+ * Spawn a sub-agent process and manage its lifecycle.
405
+ *
406
+ * Handles spawning the pi binary, buffering stdout/stderr, parsing JSON events,
407
+ * updating the rolling window, and managing abort signals with SIGTERM/SIGKILL escalation.
408
+ */
409
+ export async function runSubAgent(options: RunSubAgentOptions): Promise<{ loopDetected: boolean }> {
410
+ const { task, win, maxLines, signal, onUpdate, session, profile } = options;
411
+
412
+ // Compute command preview width (once per sub-agent run)
413
+ const lineBudget = await loadCommandPreviewWidth(task.cwd);
414
+
415
+ const effectiveLoopingToolCount = options.loopingToolCount ?? 5;
416
+
417
+ const invocation = getPiInvocation();
418
+ const args = [...invocation.args, "--mode", "json", "-p", "--no-session"];
419
+
420
+ // Inject profile-specific CLI arguments before the prompt
421
+ let profileEnv: Record<string, string> = {};
422
+ if (profile) {
423
+ const { args: profileArgs, env: envVars } = profileToArgs(profile, task.cwd, options.agentDir);
424
+ args.push(...profileArgs);
425
+ profileEnv = envVars;
426
+ }
427
+
428
+ let buffer = "";
429
+ let bufferTimeout: ReturnType<typeof setTimeout> | null = null;
430
+
431
+ // Debounced onUpdate to reduce TUI pressure
432
+ const debouncedUpdate = () => {
433
+ if (bufferTimeout) {
434
+ clearTimeout(bufferTimeout);
435
+ }
436
+ bufferTimeout = setTimeout(() => {
437
+ onUpdate();
438
+ }, 50);
439
+ };
440
+
441
+ // Validate and resolve the cwd parameter
442
+ const resolvedCwd = validateCwd(task.cwd, process.cwd());
443
+
444
+ return new Promise((resolve) => {
445
+ const proc = spawn(invocation.command, args, {
446
+ cwd: resolvedCwd,
447
+ shell: false,
448
+ stdio: ["pipe", "pipe", "pipe"],
449
+ env: { ...process.env, ...profileEnv },
450
+ });
451
+
452
+ let loopDetectedFlag = false;
453
+
454
+ const processLine = (line: string) => {
455
+ if (loopDetectedFlag) {
456
+ return;
457
+ } // short-circuit after detection
458
+ const result = handleStdoutLine(
459
+ line,
460
+ win,
461
+ maxLines,
462
+ session,
463
+ debouncedUpdate,
464
+ resolvedCwd,
465
+ lineBudget,
466
+ effectiveLoopingToolCount,
467
+ );
468
+ if (result.loopDetected) {
469
+ loopDetectedFlag = true;
470
+ proc.kill("SIGTERM"); // kill the looping process immediately
471
+ }
472
+ };
473
+
474
+ proc.stdout.on("data", (data: Buffer) => {
475
+ buffer += data.toString();
476
+ const lines = buffer.split("\n");
477
+ buffer = lines.pop() ?? "";
478
+ for (const line of lines) {
479
+ processLine(line);
480
+ }
481
+ });
482
+
483
+ proc.stderr.on("data", (data: Buffer) => {
484
+ handleStderrData(data, win, maxLines, debouncedUpdate);
485
+ });
486
+
487
+ const handleError = () => {
488
+ if (bufferTimeout) {
489
+ clearTimeout(bufferTimeout);
490
+ }
491
+ win.exitCode = 1;
492
+ win.status = "error";
493
+ win.errorMessage = win.errorMessage || "Failed to spawn sub-agent process";
494
+ syncState(win, session);
495
+ onUpdate();
496
+ resolve({ loopDetected: false });
497
+ };
498
+
499
+ proc.on("close", (code) => {
500
+ handleProcessExit(code, win, session, buffer, bufferTimeout, processLine, onUpdate);
501
+ resolve({ loopDetected: loopDetectedFlag });
502
+ });
503
+
504
+ // Write prompt via stdin to avoid OS ARG_MAX limits
505
+ proc.stdin.on("error", (err: NodeJS.ErrnoException) => {
506
+ if (err.code !== "EPIPE" && err.code !== "ERR_STREAM_DESTROYED") {
507
+ handleStderrData(
508
+ Buffer.from(`[stdin error]: ${err.message}`),
509
+ win,
510
+ maxLines,
511
+ debouncedUpdate,
512
+ );
513
+ }
514
+ });
515
+ proc.stdin.end(task.prompt);
516
+
517
+ proc.on("error", handleError);
518
+
519
+ if (signal) {
520
+ setupAbortHandler(proc, signal);
521
+ }
522
+ });
523
+ }