pi-recurse 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.
package/lib.ts ADDED
@@ -0,0 +1,854 @@
1
+ /**
2
+ * Core utilities for pi-recurse extension
3
+ */
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import * as fs from 'node:fs';
7
+ import * as os from 'node:os';
8
+ import * as path from 'node:path';
9
+ import type {
10
+ GuardrailConfig,
11
+ RecurseEnvironment,
12
+ RecurseState,
13
+ SubagentResult,
14
+ SubagentUsage,
15
+ SubagentProgress,
16
+ } from './types.js';
17
+
18
+ export const DEFAULTS = {
19
+ MAX_DEPTH: 3,
20
+ MAX_CALLS: 100,
21
+ TIMEOUT: 600, // 10 minutes
22
+ CONCURRENCY: 4,
23
+ DISABLE_TOOL_AT_DEPTH: 3,
24
+ } as const;
25
+
26
+ export function getCurrentDepth(): number {
27
+ return parseInt(process.env.RLM_DEPTH || '0', 10);
28
+ }
29
+
30
+ export function getMaxDepth(): number {
31
+ return parseInt(process.env.RLM_MAX_DEPTH || String(DEFAULTS.MAX_DEPTH), 10);
32
+ }
33
+
34
+ export function getCallCount(): number {
35
+ return parseInt(process.env.RLM_CALL_COUNT || '0', 10);
36
+ }
37
+
38
+ export function getTraceId(): string {
39
+ return process.env.RLM_TRACE_ID || generateTraceId();
40
+ }
41
+
42
+ export function generateTraceId(): string {
43
+ return Math.random().toString(36).substring(2, 10);
44
+ }
45
+
46
+ export function getStartTime(): number {
47
+ return parseInt(process.env.RLM_START_TIME || String(Date.now()), 10);
48
+ }
49
+
50
+ export function checkDepthGuard(): { allowed: boolean; reason?: string } {
51
+ const depth = getCurrentDepth();
52
+ const maxDepth = getMaxDepth();
53
+
54
+ if (depth >= maxDepth) {
55
+ return {
56
+ allowed: false,
57
+ reason: `Max depth exceeded: at depth ${depth} of ${maxDepth}`,
58
+ };
59
+ }
60
+ return { allowed: true };
61
+ }
62
+
63
+ export function checkCallGuard(maxCalls?: number): { allowed: boolean; reason?: string } {
64
+ const current = getCallCount();
65
+ const limit = maxCalls || parseInt(process.env.RLM_MAX_CALLS || String(DEFAULTS.MAX_CALLS), 10);
66
+
67
+ if (current >= limit) {
68
+ return {
69
+ allowed: false,
70
+ reason: `Max calls exceeded: ${current} of ${limit}`,
71
+ };
72
+ }
73
+ return { allowed: true };
74
+ }
75
+
76
+ export function checkTimeoutGuard(timeout?: number): { allowed: boolean; reason?: string } {
77
+ const startTime = getStartTime();
78
+ const limit = timeout || parseInt(process.env.RLM_TIMEOUT || String(DEFAULTS.TIMEOUT), 10);
79
+ const elapsed = (Date.now() - startTime) / 1000;
80
+
81
+ if (elapsed > limit) {
82
+ return {
83
+ allowed: false,
84
+ reason: `Timeout exceeded: ${elapsed.toFixed(0)}s of ${limit}s`,
85
+ };
86
+ }
87
+ return { allowed: true };
88
+ }
89
+
90
+ export function buildChildEnvironment(): NodeJS.ProcessEnv {
91
+ const currentDepth = getCurrentDepth();
92
+ const nextDepth = currentDepth + 1;
93
+
94
+ return {
95
+ ...process.env,
96
+ RLM_DEPTH: String(nextDepth),
97
+ RLM_CALL_COUNT: String(getCallCount() + 1),
98
+ RLM_TRACE_ID: getTraceId(),
99
+ RLM_START_TIME: String(getStartTime()),
100
+ };
101
+ }
102
+
103
+ export interface SpawnOptions {
104
+ prompt: string;
105
+ context?: string;
106
+ fork?: boolean;
107
+ timeout?: number;
108
+ model?: string;
109
+ provider?: string;
110
+ /** Callback for streaming progress updates */
111
+ onUpdate?: (data: { output: string; progress: SubagentProgress }) => void;
112
+ }
113
+
114
+ export async function spawnSubagent(options: SpawnOptions): Promise<SubagentResult> {
115
+ const startTime = Date.now();
116
+ const id = Math.random().toString(36).substring(2, 8);
117
+ const currentDepth = getCurrentDepth();
118
+ const nextDepth = currentDepth + 1;
119
+ const onUpdate = options.onUpdate;
120
+
121
+ // Check guardrails before spawning
122
+ const depthCheck = checkDepthGuard();
123
+ if (!depthCheck.allowed) {
124
+ return {
125
+ id,
126
+ success: false,
127
+ output: '',
128
+ error: depthCheck.reason,
129
+ durationMs: Date.now() - startTime,
130
+ };
131
+ }
132
+
133
+ const timeoutCheck = checkTimeoutGuard(options.timeout);
134
+ if (!timeoutCheck.allowed) {
135
+ return {
136
+ id,
137
+ success: false,
138
+ output: '',
139
+ error: timeoutCheck.reason,
140
+ durationMs: Date.now() - startTime,
141
+ };
142
+ }
143
+
144
+ // Context size limit - prevent "prompt too long" errors
145
+ // Max ~200k tokens ≈ 4M chars at 20 chars/token
146
+ const MAX_CONTEXT_CHARS = 4_000_000;
147
+ let context = options.context || '';
148
+ if (context.length > MAX_CONTEXT_CHARS) {
149
+ context =
150
+ context.slice(0, MAX_CONTEXT_CHARS) +
151
+ `\n\n[Context truncated: ${context.length} chars > ${MAX_CONTEXT_CHARS} limit]`;
152
+ }
153
+
154
+ return new Promise((resolve) => {
155
+ const env = buildChildEnvironment();
156
+ const args = ['--mode', 'json', '-p', options.prompt];
157
+
158
+ // Session file handling (like ypi/rlm_query)
159
+ const sessionDir = process.env.RLM_SESSION_DIR;
160
+ const traceId = getTraceId();
161
+ let childSessionFile: string | undefined;
162
+
163
+ if (sessionDir) {
164
+ if (!fs.existsSync(sessionDir)) {
165
+ fs.mkdirSync(sessionDir, { recursive: true });
166
+ }
167
+
168
+ childSessionFile = path.join(sessionDir, `${traceId}_d${nextDepth}_${id}.jsonl`);
169
+
170
+ if (options.fork) {
171
+ const parentSessionFile = process.env.RLM_SESSION_FILE;
172
+ if (parentSessionFile && fs.existsSync(parentSessionFile)) {
173
+ fs.copyFileSync(parentSessionFile, childSessionFile);
174
+ }
175
+ }
176
+
177
+ args.push('--session', childSessionFile);
178
+ env.RLM_SESSION_FILE = childSessionFile;
179
+ } else if (options.fork) {
180
+ // Fork requested but no session dir - we need a temp session file
181
+ const tmpDir = os.tmpdir();
182
+ childSessionFile = path.join(tmpDir, `rlm_fork_${traceId}_${id}.jsonl`);
183
+ const parentSessionFile = process.env.RLM_SESSION_FILE;
184
+ if (parentSessionFile && fs.existsSync(parentSessionFile)) {
185
+ fs.copyFileSync(parentSessionFile, childSessionFile);
186
+ args.push('--session', childSessionFile);
187
+ env.RLM_SESSION_FILE = childSessionFile;
188
+ } else {
189
+ // No parent session to fork - use no session
190
+ args.push('--no-session');
191
+ }
192
+ } else {
193
+ args.push('--no-session');
194
+ }
195
+
196
+ // Track temp files for cleanup
197
+ const tmpDir = os.tmpdir();
198
+ const tempFiles: string[] = [];
199
+
200
+ // Add system prompt if available
201
+ const systemPromptPath = process.env.RLM_SYSTEM_PROMPT;
202
+ if (systemPromptPath && fs.existsSync(systemPromptPath)) {
203
+ args.push('--system-prompt', systemPromptPath);
204
+ }
205
+
206
+ // Add instruction based on depth (nextDepth already defined in outer scope)
207
+ const maxDepth = getMaxDepth();
208
+ const isAtMaxDepth = nextDepth >= maxDepth;
209
+
210
+ if (isAtMaxDepth) {
211
+ // At max depth: one-shot mode, no further recursion possible
212
+ const oneShotInstruction = `
213
+ ## ONE-SHOT MODE - MANDATORY (MAX DEPTH)
214
+
215
+ You are at recursion depth ${nextDepth} of ${maxDepth}. **NO FURTHER RECURSION IS POSSIBLE.**
216
+
217
+ You MUST:
218
+ 1. Complete your task in ONE response cycle (analyze and output immediately)
219
+ 2. NEVER ask the user for clarification or additional input
220
+ 3. If you need file contents, READ them yourself using the read tool
221
+ 4. If information is missing, make reasonable assumptions and proceed
222
+ 5. Do NOT output phrases like "Once you provide..." or "I'll analyze when..."
223
+ 6. After outputting your analysis, IMMEDIATELY call: bash({ command: "exit 0" })
224
+ 7. You have a hard limit of 15 tool calls - after that you will be terminated
225
+
226
+ VIOLATING THESE RULES WILL CAUSE YOUR OUTPUT TO BE REJECTED.
227
+ `;
228
+
229
+ const oneShotPath = path.join(tmpDir, `rlm_oneshot_${id}.md`);
230
+ fs.writeFileSync(oneShotPath, oneShotInstruction, { mode: 0o600 });
231
+ args.push('--append-system-prompt', oneShotPath);
232
+ tempFiles.push(oneShotPath);
233
+ } else {
234
+ // Below max depth: enable true recursion with guidance
235
+ const recursionGuidance = `
236
+ ## RECURSION ENABLED - DEPTH ${nextDepth}/${maxDepth}
237
+
238
+ You are a subagent with access to the \\\`recurse\\\` tool. You MAY use recursion when necessary.
239
+
240
+ ### When to recurse (RECOMMENDED):
241
+ - Task requires analyzing 10+ files independently → \\\`recurse({ mode: \\'parallel\\', tasks: [...] })\\\`
242
+ - Task has sequential dependencies (summarize → analyze → plan) → \\\`recurse({ mode: \\'chain\\', chain: [...] })\\\`
243
+ - File is too large for your context window → \\\`recurse({ mode: \\'single\\', prompt: \\'Process chunk...\\' })\\\`
244
+ - Complex refactor across multiple files → Divide and conquer with parallel tasks
245
+
246
+ ### Rules for recursion:
247
+ 1. **Prefer direct answers for simple tasks** (< 5 files, < 200 lines each)
248
+ 2. **Check remaining depth before recursing** - you are at depth ${nextDepth}, max is ${maxDepth}
249
+ 3. **Return compact results** - parent aggregates, don't write essays
250
+ 4. **NEVER ask users for clarification** - read files yourself, make assumptions, recurse if stuck
251
+ 5. **One-shot mode**: Complete analysis and call \\\`bash({ command: \\'exit 0\\' })\\\` when done
252
+
253
+ ### Example recursive call:
254
+ \\\`\\\`\\\`typescript
255
+ recurse({
256
+ mode: "parallel",
257
+ tasks: files.map(f => ({
258
+ id: f,
259
+ prompt: \\\`Review \\\${f}: identify bugs\\\`
260
+ })),
261
+ concurrency: 4
262
+ });
263
+ \\\`\\\`\\\`
264
+
265
+ You have ${maxDepth - nextDepth} recursion levels remaining. Use them wisely.
266
+ `;
267
+
268
+ const guidancePath = path.join(tmpDir, `rlm_recursion_${id}.md`);
269
+ fs.writeFileSync(guidancePath, recursionGuidance, { mode: 0o600 });
270
+ args.push('--append-system-prompt', guidancePath);
271
+ tempFiles.push(guidancePath);
272
+ }
273
+
274
+ // Model override (use --models like pi-subagents, not --model)
275
+ if (options.model) {
276
+ args.push('--models', options.model);
277
+ } else if (env.RLM_CHILD_MODEL) {
278
+ args.push('--models', env.RLM_CHILD_MODEL);
279
+ }
280
+
281
+ // Provider override
282
+ if (options.provider) {
283
+ args.push('--provider', options.provider);
284
+ } else if (env.RLM_CHILD_PROVIDER) {
285
+ args.push('--provider', env.RLM_CHILD_PROVIDER);
286
+ }
287
+
288
+ // Resolve pi command properly (like pi-subagents)
289
+ const spawnCommand = getPiSpawnCommand(args);
290
+
291
+ // Handle context: if present and small, append to prompt; if large, write to temp file
292
+ let contextFile: string | undefined;
293
+ const MAX_PROMPT_CHARS = 100_000; // Approximate safe limit for -p arg
294
+
295
+ if (context) {
296
+ if (context.length > MAX_PROMPT_CHARS) {
297
+ // Write large context to temp file and reference it in prompt
298
+ const tmpDir = os.tmpdir();
299
+ contextFile = path.join(tmpDir, `rlm_ctx_${id}.txt`);
300
+ fs.writeFileSync(contextFile, context, { mode: 0o600 });
301
+ // Modify prompt to reference the file
302
+ const modifiedPrompt = `${options.prompt}\n\n[Context available at: ${contextFile}]`;
303
+ // Replace the last arg (original prompt) with modified
304
+ spawnCommand.args[spawnCommand.args.length - 1] = modifiedPrompt;
305
+ } else {
306
+ // Small context - append directly to prompt
307
+ const modifiedPrompt = `${options.prompt}\n\n${context}`;
308
+ spawnCommand.args[spawnCommand.args.length - 1] = modifiedPrompt;
309
+ }
310
+ }
311
+
312
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
313
+ env,
314
+ stdio: ['ignore', 'pipe', 'pipe'], // Ignore stdin like pi-messenger
315
+ }) as ReturnType<typeof spawn>;
316
+
317
+ let stderr = '';
318
+ let timedOut = false;
319
+ let processClosed = false;
320
+
321
+ // Result accumulator
322
+ const result: SubagentResult = {
323
+ id,
324
+ success: false,
325
+ output: '',
326
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
327
+ durationMs: 0,
328
+ progress: {
329
+ status: 'running',
330
+ recentOutput: [],
331
+ recentTools: [],
332
+ toolCount: 0,
333
+ tokens: 0,
334
+ durationMs: 0,
335
+ },
336
+ };
337
+
338
+ // Throttled update mechanism (like pi-subagents)
339
+ let lastUpdateTime = 0;
340
+ let updatePending = false;
341
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
342
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
343
+ const UPDATE_THROTTLE_MS = 50;
344
+
345
+ // Track output stabilization to detect "hung but done" subagents (workaround for pi-mono #2584)
346
+ let lastOutputTime = Date.now();
347
+ let outputStabilizeTimer: ReturnType<typeof setTimeout> | null = null;
348
+ const OUTPUT_STABILIZE_MS = 4000; // 4 seconds of no output = assume done
349
+
350
+ const checkOutputStabilized = () => {
351
+ if (processClosed) return;
352
+ const timeSinceOutput = Date.now() - lastOutputTime;
353
+ if (timeSinceOutput >= OUTPUT_STABILIZE_MS) {
354
+ // Output has stabilized - assume subagent is done but hung
355
+ // This is a workaround for pi-mono #2584 where extensions keep process alive
356
+ result.error = `Subagent output stabilized but process did not exit (pi-mono #2584 workaround). Terminating after ${timeSinceOutput}ms of no output.`;
357
+ child.kill('SIGTERM');
358
+ } else {
359
+ // Schedule next check
360
+ outputStabilizeTimer = setTimeout(
361
+ checkOutputStabilized,
362
+ OUTPUT_STABILIZE_MS - timeSinceOutput
363
+ );
364
+ }
365
+ };
366
+
367
+ const scheduleUpdate = () => {
368
+ if (!onUpdate || processClosed) return;
369
+ const now = Date.now();
370
+ const elapsed = now - lastUpdateTime;
371
+
372
+ if (elapsed >= UPDATE_THROTTLE_MS) {
373
+ if (pendingTimer) {
374
+ clearTimeout(pendingTimer);
375
+ pendingTimer = null;
376
+ }
377
+ lastUpdateTime = now;
378
+ updatePending = false;
379
+ result.progress!.durationMs = now - startTime;
380
+ onUpdate({
381
+ output: result.output,
382
+ progress: result.progress!,
383
+ });
384
+ } else if (!updatePending) {
385
+ updatePending = true;
386
+ pendingTimer = setTimeout(() => {
387
+ pendingTimer = null;
388
+ if (updatePending && !processClosed) {
389
+ updatePending = false;
390
+ lastUpdateTime = Date.now();
391
+ result.progress!.durationMs = Date.now() - startTime;
392
+ onUpdate({
393
+ output: result.output,
394
+ progress: result.progress!,
395
+ });
396
+ }
397
+ }, UPDATE_THROTTLE_MS - elapsed);
398
+ }
399
+ };
400
+
401
+ // Heartbeat: force update every second even if no data arrives
402
+ // This prevents UI from appearing stuck during large outputs
403
+ if (onUpdate) {
404
+ heartbeatTimer = setInterval(() => {
405
+ if (!processClosed && onUpdate) {
406
+ result.progress!.durationMs = Date.now() - startTime;
407
+ onUpdate({
408
+ output: result.output,
409
+ progress: result.progress!,
410
+ });
411
+ }
412
+ }, 1000);
413
+ }
414
+
415
+ // Handle timeout
416
+ const timeoutMs = (options.timeout || DEFAULTS.TIMEOUT) * 1000;
417
+ const timeoutId = setTimeout(() => {
418
+ timedOut = true;
419
+ child.kill('SIGTERM');
420
+ }, timeoutMs);
421
+
422
+ // JSONL streaming parser
423
+ let buf = '';
424
+
425
+ const processLine = (line: string) => {
426
+ if (!line.trim()) return;
427
+
428
+ try {
429
+ const evt = JSON.parse(line) as {
430
+ type?: string;
431
+ message?: {
432
+ role?: string;
433
+ content?: unknown;
434
+ usage?: SubagentUsage;
435
+ errorMessage?: string;
436
+ model?: string;
437
+ };
438
+ toolName?: string;
439
+ args?: Record<string, unknown>;
440
+ };
441
+
442
+ const now = Date.now();
443
+ result.progress!.durationMs = now - startTime;
444
+
445
+ if (evt.type === 'tool_execution_start') {
446
+ result.progress!.toolCount++;
447
+ result.progress!.currentTool = evt.toolName;
448
+ result.progress!.currentToolArgs = extractToolArgsPreview(evt.args || {});
449
+ // Force immediate update on tool start
450
+ lastUpdateTime = 0;
451
+ scheduleUpdate();
452
+ }
453
+
454
+ if (evt.type === 'tool_execution_end') {
455
+ if (result.progress!.currentTool) {
456
+ result.progress!.recentTools.unshift({
457
+ tool: result.progress!.currentTool,
458
+ args: result.progress!.currentToolArgs || '',
459
+ endMs: now,
460
+ });
461
+ if (result.progress!.recentTools.length > 5) {
462
+ result.progress!.recentTools.pop();
463
+ }
464
+ }
465
+ result.progress!.currentTool = undefined;
466
+ result.progress!.currentToolArgs = undefined;
467
+ scheduleUpdate();
468
+ }
469
+
470
+ if (evt.type === 'message_end' && evt.message) {
471
+ if (evt.message.role === 'assistant') {
472
+ result.usage!.turns = (result.usage!.turns || 0) + 1;
473
+ const u = evt.message.usage;
474
+ if (u) {
475
+ result.usage!.input += u.input || 0;
476
+ result.usage!.output += u.output || 0;
477
+ result.usage!.cacheRead = (result.usage!.cacheRead || 0) + (u.cacheRead || 0);
478
+ result.usage!.cacheWrite = (result.usage!.cacheWrite || 0) + (u.cacheWrite || 0);
479
+ result.usage!.cost = (result.usage!.cost || 0) + (u.cost || 0);
480
+ result.progress!.tokens = result.usage!.input + result.usage!.output;
481
+ }
482
+ if (!result.model && evt.message.model) {
483
+ (result as any).model = evt.message.model;
484
+ }
485
+ if (evt.message.errorMessage) {
486
+ result.error = evt.message.errorMessage;
487
+ }
488
+
489
+ // Extract text content
490
+ const text = extractTextFromContent(evt.message.content);
491
+ if (text) {
492
+ const lines = text
493
+ .split('\n')
494
+ .filter((l) => l.trim())
495
+ .slice(-10);
496
+ result.progress!.recentOutput.push(...lines);
497
+ if (result.progress!.recentOutput.length > 50) {
498
+ result.progress!.recentOutput.splice(0, result.progress!.recentOutput.length - 50);
499
+ }
500
+ // Append to full output
501
+ result.output += (result.output ? '\n' : '') + text;
502
+ }
503
+ }
504
+ scheduleUpdate();
505
+ }
506
+
507
+ if (evt.type === 'tool_result_end' && evt.message) {
508
+ // Also capture tool result text
509
+ const toolText = extractTextFromContent(evt.message.content);
510
+ if (toolText) {
511
+ const toolLines = toolText
512
+ .split('\n')
513
+ .filter((l) => l.trim())
514
+ .slice(-10);
515
+ result.progress!.recentOutput.push(...toolLines);
516
+ if (result.progress!.recentOutput.length > 50) {
517
+ result.progress!.recentOutput.splice(0, result.progress!.recentOutput.length - 50);
518
+ }
519
+ }
520
+ scheduleUpdate();
521
+ }
522
+ } catch {
523
+ // Non-JSON lines are expected; only structured events are parsed
524
+ }
525
+ };
526
+
527
+ // Collect stderr
528
+ child.stderr?.on('data', (data: Buffer) => {
529
+ stderr += data.toString();
530
+ });
531
+
532
+ // Stream stdout (JSONL events)
533
+ child.stdout?.on('data', (data: Buffer) => {
534
+ lastOutputTime = Date.now(); // Reset output stabilization timer
535
+ if (outputStabilizeTimer) {
536
+ clearTimeout(outputStabilizeTimer);
537
+ }
538
+ outputStabilizeTimer = setTimeout(checkOutputStabilized, OUTPUT_STABILIZE_MS);
539
+
540
+ buf += data.toString();
541
+ const lines = buf.split('\n');
542
+ buf = lines.pop() || '';
543
+ lines.forEach(processLine);
544
+ scheduleUpdate();
545
+ });
546
+
547
+ // Handle completion - listen to both 'close' and 'exit' for robustness
548
+ let resolved = false;
549
+ const finalize = (code: number | null) => {
550
+ if (resolved) return;
551
+ resolved = true;
552
+ processClosed = true;
553
+ clearTimeout(timeoutId);
554
+ if (pendingTimer) {
555
+ clearTimeout(pendingTimer);
556
+ pendingTimer = null;
557
+ }
558
+ if (heartbeatTimer) {
559
+ clearInterval(heartbeatTimer);
560
+ heartbeatTimer = null;
561
+ }
562
+ if (outputStabilizeTimer) {
563
+ clearTimeout(outputStabilizeTimer);
564
+ outputStabilizeTimer = null;
565
+ }
566
+
567
+ // Clean up temp files
568
+ for (const tmpFile of [contextFile, ...tempFiles]) {
569
+ if (tmpFile) {
570
+ try {
571
+ fs.unlinkSync(tmpFile);
572
+ } catch {
573
+ // Ignore cleanup errors
574
+ }
575
+ }
576
+ }
577
+
578
+ // Process remaining buffer
579
+ if (buf.trim()) processLine(buf);
580
+
581
+ const durationMs = Date.now() - startTime;
582
+ result.durationMs = durationMs;
583
+
584
+ // Check if this was an output stabilization kill with actual output (pi-mono #2584 workaround)
585
+ // In this case, the subagent completed its work but the process didn't exit cleanly
586
+ const wasOutputStabilizationKill = result.error?.includes('output stabilized');
587
+ const hasOutput = result.output.trim().length > 0;
588
+
589
+ if (wasOutputStabilizationKill && hasOutput) {
590
+ // Count as success - the subagent produced output before we killed the hung process
591
+ result.success = true;
592
+ result.stopReason = 'output-stabilization';
593
+ result.error = undefined; // Clear the error since we have valid output
594
+ result.progress!.status = 'completed';
595
+ } else {
596
+ result.success = code === 0 && !timedOut && !result.error;
597
+ result.progress!.status = result.success ? 'completed' : 'failed';
598
+
599
+ // Set stop reason for tracking
600
+ if (timedOut) {
601
+ result.stopReason = 'timeout';
602
+ } else if (result.error) {
603
+ result.stopReason = 'error';
604
+ } else if (code !== null && code !== 0) {
605
+ result.stopReason = 'stopped'; // matches pi-messenger terminology
606
+ } else {
607
+ result.stopReason = 'completed';
608
+ }
609
+ }
610
+
611
+ // Check for prompt too long error in stderr
612
+ if (stderr) {
613
+ if (stderr.includes('prompt is too long')) {
614
+ result.error = `Context too large for subagent. Pass smaller context or use file references. Original: ${stderr}`;
615
+ } else if (code !== 0 && !result.error) {
616
+ result.error = stderr.trim();
617
+ }
618
+ }
619
+
620
+ if (timedOut) {
621
+ result.error = `Timeout after ${options.timeout || DEFAULTS.TIMEOUT}s`;
622
+ }
623
+
624
+ // Clean up empty usage
625
+ if (result.usage && !result.usage.input && !result.usage.output && !result.usage.cost) {
626
+ delete result.usage;
627
+ }
628
+ // Clean up progress if not needed externally
629
+ if (!onUpdate) {
630
+ delete result.progress;
631
+ }
632
+
633
+ resolve(result);
634
+ };
635
+
636
+ child.on('close', (code) => finalize(code));
637
+ child.on('exit', (code) => finalize(code));
638
+
639
+ // Check if process already exited (happens with immediate failures)
640
+ if (child.exitCode !== null) {
641
+ finalize(child.exitCode);
642
+ } else if (child.killed) {
643
+ finalize(null);
644
+ }
645
+
646
+ // Safety: if process errors before spawning
647
+ child.on('error', (err) => {
648
+ if (resolved) return;
649
+ resolved = true;
650
+ processClosed = true;
651
+ clearTimeout(timeoutId);
652
+ if (pendingTimer) {
653
+ clearTimeout(pendingTimer);
654
+ pendingTimer = null;
655
+ }
656
+ if (heartbeatTimer) {
657
+ clearInterval(heartbeatTimer);
658
+ heartbeatTimer = null;
659
+ }
660
+ // Clean up temp files
661
+ for (const tmpFile of [contextFile, ...tempFiles]) {
662
+ if (tmpFile) {
663
+ try {
664
+ fs.unlinkSync(tmpFile);
665
+ } catch {
666
+ // Ignore cleanup errors
667
+ }
668
+ }
669
+ }
670
+ resolve({
671
+ id,
672
+ success: false,
673
+ output: result.output,
674
+ error: `Failed to spawn subagent: ${err.message}`,
675
+ durationMs: Date.now() - startTime,
676
+ });
677
+ });
678
+
679
+ // Context is now passed via temp file for large payloads, or prompt for small ones
680
+ // No stdin handling needed - matches pi-messenger approach
681
+ });
682
+ }
683
+
684
+ // Helper: Extract text from message content
685
+ function extractTextFromContent(content: unknown): string {
686
+ if (typeof content === 'string') return content;
687
+ if (Array.isArray(content)) {
688
+ return content
689
+ .map((c: any) => {
690
+ if (typeof c === 'string') return c;
691
+ if (c && typeof c === 'object') {
692
+ if (c.text) return c.text;
693
+ if (c.type === 'text') return c.text || '';
694
+ }
695
+ return '';
696
+ })
697
+ .join('');
698
+ }
699
+ if (content && typeof content === 'object') {
700
+ const c = content as Record<string, unknown>;
701
+ if (typeof c.text === 'string') return c.text;
702
+ }
703
+ return '';
704
+ }
705
+
706
+ // Helper: Extract tool args preview
707
+ function extractToolArgsPreview(args: Record<string, unknown>): string {
708
+ const keys = Object.keys(args).slice(0, 3);
709
+ const preview = keys
710
+ .map((k) => {
711
+ const v = args[k];
712
+ if (typeof v === 'string') return `${k}: "${v.slice(0, 30)}${v.length > 30 ? '...' : ''}"`;
713
+ return `${k}: ${JSON.stringify(v).slice(0, 40)}`;
714
+ })
715
+ .join(', ');
716
+ return keys.length < Object.keys(args).length ? `${preview}, ...` : preview;
717
+ }
718
+
719
+ export async function runParallel<T, R>(
720
+ items: T[],
721
+ fn: (item: T) => Promise<R>,
722
+ concurrency: number
723
+ ): Promise<R[]> {
724
+ const results: R[] = new Array(items.length);
725
+ const executing = new Set<Promise<void>>();
726
+
727
+ for (const [index, item] of items.entries()) {
728
+ const promise = fn(item).then((result) => {
729
+ results[index] = result;
730
+ executing.delete(promise); // Self-remove when done
731
+ });
732
+
733
+ executing.add(promise);
734
+
735
+ if (executing.size >= concurrency) {
736
+ await Promise.race(executing);
737
+ }
738
+ }
739
+
740
+ await Promise.all(executing);
741
+ return results;
742
+ }
743
+
744
+ export function getRecursiveSystemPrompt(basePrompt: string, depth: number): string {
745
+ const isDeep = depth > 0;
746
+
747
+ const recursionSection = `
748
+ ## Recursive Agent Context
749
+
750
+ You are at recursion **depth ${depth}**${isDeep ? ' (sub-agent)' : ' (root agent)'}.
751
+
752
+ ${
753
+ isDeep
754
+ ? `
755
+ **Guidelines for sub-agents:**
756
+ - Prefer **direct answers** over further delegation
757
+ - Only recurse if the task truly requires more context windows
758
+ - Check remaining budget/depth before spawning children
759
+ - Return compact, actionable results
760
+ `
761
+ : `
762
+ **Guidelines for root agents:**
763
+ - Decompose large tasks via \`recurse\\\
764
+ - Spawn subagents in parallel for independent work
765
+ - Aggregate and synthesize results
766
+ - Monitor total cost and depth usage
767
+ `
768
+ }
769
+
770
+ Environment:
771
+ - RLM_DEPTH=${depth}
772
+ - RLM_MAX_DEPTH=${getMaxDepth()}
773
+ - RLM_TRACE_ID=${getTraceId()}
774
+ `;
775
+
776
+ return basePrompt + recursionSection;
777
+ }
778
+
779
+ export function loadAccumulatedCost(): number {
780
+ const costFile = process.env.RLM_COST_FILE;
781
+ if (!costFile || !fs.existsSync(costFile)) {
782
+ return 0;
783
+ }
784
+ try {
785
+ const content = fs.readFileSync(costFile, 'utf-8');
786
+ return parseFloat(content) || 0;
787
+ } catch {
788
+ return 0;
789
+ }
790
+ }
791
+
792
+ export function saveAccumulatedCost(cost: number): void {
793
+ const costFile = process.env.RLM_COST_FILE;
794
+ if (costFile) {
795
+ try {
796
+ fs.writeFileSync(costFile, cost.toFixed(6), 'utf-8');
797
+ } catch {
798
+ // Ignore write errors
799
+ }
800
+ }
801
+ }
802
+
803
+ export function checkBudgetGuard(budget?: number): { allowed: boolean; remaining: number } {
804
+ const limit = budget || parseFloat(process.env.RLM_BUDGET || '0');
805
+ if (limit <= 0) {
806
+ return { allowed: true, remaining: Infinity };
807
+ }
808
+
809
+ const current = loadAccumulatedCost();
810
+ const remaining = limit - current;
811
+
812
+ return {
813
+ allowed: remaining > 0,
814
+ remaining,
815
+ };
816
+ }
817
+
818
+ interface PiSpawnCommand {
819
+ command: string;
820
+ args: string[];
821
+ }
822
+
823
+ /**
824
+ * Resolve the pi command properly.
825
+ * On Windows: uses process.execPath with the pi CLI script
826
+ * On other platforms: uses "pi" directly
827
+ */
828
+ function getPiSpawnCommand(args: string[]): PiSpawnCommand {
829
+ // On Windows, we need to spawn node with the pi CLI script
830
+ if (process.platform === 'win32') {
831
+ try {
832
+ // Try to find pi CLI via require
833
+ const piPkg = require.resolve('@earendil-works/pi-coding-agent/package.json');
834
+ const piRoot = path.dirname(piPkg);
835
+ const pkg = JSON.parse(fs.readFileSync(piPkg, 'utf-8'));
836
+ const binField = pkg.bin;
837
+ const binPath =
838
+ typeof binField === 'string'
839
+ ? binField
840
+ : (binField?.pi ?? Object.values(binField ?? {})[0]);
841
+ if (binPath) {
842
+ const cliPath = path.resolve(piRoot, binPath);
843
+ if (fs.existsSync(cliPath)) {
844
+ return { command: process.execPath, args: [cliPath, ...args] };
845
+ }
846
+ }
847
+ } catch {
848
+ // Fall through to default
849
+ }
850
+ }
851
+
852
+ // Default: use pi from PATH
853
+ return { command: 'pi', args };
854
+ }