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/index.ts ADDED
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Pi Recurse Extension
3
+ *
4
+ * Enables programmatic recursive subagent spawning with guardrails.
5
+ * The key capability: LLM makes ONE tool call, extension code handles
6
+ * parallel spawning and result aggregation without autoregressive steps.
7
+ *
8
+ * Usage:
9
+ * recurse({ mode: "single", prompt: "Analyze src/auth.ts" })
10
+ * recurse({ mode: "parallel", tasks: [...], concurrency: 4 })
11
+ * recurse({ mode: "chain", chain: [...] })
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+ import { Type } from '@sinclair/typebox';
16
+ import { Text } from '@earendil-works/pi-tui';
17
+ import type {
18
+ RecurseParams,
19
+ RecurseResult,
20
+ SubagentResult,
21
+ SubagentProgress,
22
+ RecurseSingleParams,
23
+ RecurseParallelParams,
24
+ RecurseChainParams,
25
+ } from './types.js';
26
+ import {
27
+ getCurrentDepth,
28
+ getMaxDepth,
29
+ getTraceId,
30
+ checkDepthGuard,
31
+ checkCallGuard,
32
+ checkTimeoutGuard,
33
+ checkBudgetGuard,
34
+ spawnSubagent,
35
+ runParallel,
36
+ getRecursiveSystemPrompt,
37
+ loadAccumulatedCost,
38
+ saveAccumulatedCost,
39
+ DEFAULTS,
40
+ } from './lib.js';
41
+ import {
42
+ renderParallelStatus,
43
+ renderSubagentStatus,
44
+ formatDuration,
45
+ formatTokens,
46
+ renderRecurseTree,
47
+ buildRecurseTree,
48
+ } from './formatters.js';
49
+ import { formatAgentLabel } from './names.js';
50
+
51
+ export default function piRecurseExtension(pi: ExtensionAPI) {
52
+ const currentDepth = getCurrentDepth();
53
+ const maxDepth = getMaxDepth();
54
+ const isDeep = currentDepth > 0;
55
+
56
+ // At deep depths, disable the recurse tool entirely
57
+ const disableToolAt = parseInt(
58
+ process.env.RLM_DISABLE_TOOL_AT || String(DEFAULTS.DISABLE_TOOL_AT_DEPTH),
59
+ 10
60
+ );
61
+ const toolEnabled = currentDepth < disableToolAt;
62
+
63
+ pi.on('before_agent_start', async (event) => {
64
+ const modifiedPrompt = getRecursiveSystemPrompt(event.systemPrompt, currentDepth);
65
+ return { systemPrompt: modifiedPrompt };
66
+ });
67
+
68
+ pi.on('session_start', async (_event, ctx) => {
69
+ if (ctx.hasUI && toolEnabled) {
70
+ const budget = checkBudgetGuard();
71
+ const statusText =
72
+ budget.remaining !== Infinity
73
+ ? `∞ depth ${currentDepth}/${maxDepth} · $${budget.remaining.toFixed(2)}`
74
+ : `∞ depth ${currentDepth}/${maxDepth}`;
75
+ ctx.ui.setStatus('recurse', statusText);
76
+ }
77
+ });
78
+
79
+ pi.registerTool({
80
+ name: 'recurse',
81
+ label: 'Recurse',
82
+ description: toolEnabled
83
+ ? `Spawn subagents programmatically. Mode "single" for one task, "parallel" for concurrent batch processing, "chain" for sequential dependency chains. Returns aggregated results.`
84
+ : `[DISABLED at depth ${currentDepth}] Work directly instead of recursing.`,
85
+ promptSnippet: 'Delegate work to subagents in single/parallel/chain mode',
86
+ promptGuidelines: [
87
+ "Use recurse({ mode: 'single', prompt: '...' }) for one-off delegation.",
88
+ "Use recurse({ mode: 'parallel', tasks: [...] }) for independent batch work.",
89
+ "Use recurse({ mode: 'chain', chain: [...] }) when each step depends on the previous.",
90
+ 'Subagents return compact results; aggregate and synthesize in parent.',
91
+ 'Check result.stats before proceeding with expensive operations.',
92
+ ],
93
+
94
+ parameters: Type.Object({
95
+ mode: StringEnum(['single', 'parallel', 'chain'] as const, {
96
+ description: 'Execution mode: single task, parallel batch, or sequential chain',
97
+ }),
98
+
99
+ // Single mode params
100
+ prompt: Type.Optional(Type.String({ description: 'Prompt for single mode' })),
101
+ context: Type.Optional(Type.String({ description: 'Context data to pipe to subagent' })),
102
+ fork: Type.Optional(Type.Boolean({ description: 'Fork session history (default: false)' })),
103
+
104
+ // Parallel mode params
105
+ tasks: Type.Optional(
106
+ Type.Array(
107
+ Type.Object({
108
+ id: Type.String({ description: 'Task identifier' }),
109
+ prompt: Type.String({ description: 'Subagent prompt' }),
110
+ context: Type.Optional(Type.String({ description: 'Task-specific context' })),
111
+ }),
112
+ { description: 'Tasks for parallel execution' }
113
+ )
114
+ ),
115
+ concurrency: Type.Optional(
116
+ Type.Number({
117
+ description: 'Max concurrent subagents',
118
+ default: DEFAULTS.CONCURRENCY,
119
+ })
120
+ ),
121
+ timeoutPerTask: Type.Optional(
122
+ Type.Number({
123
+ description: 'Timeout per task in seconds',
124
+ })
125
+ ),
126
+
127
+ // Chain mode params
128
+ chain: Type.Optional(
129
+ Type.Array(
130
+ Type.Object({
131
+ id: Type.String({ description: 'Step identifier' }),
132
+ prompt: Type.String({ description: 'Step prompt (use {previous} for prior output)' }),
133
+ }),
134
+ { description: 'Sequential chain steps' }
135
+ )
136
+ ),
137
+ }),
138
+
139
+ async execute(toolCallId, rawParams, signal, onUpdate, ctx) {
140
+ const params = rawParams as RecurseParams;
141
+ const startTime = Date.now();
142
+
143
+ const depthCheck = checkDepthGuard();
144
+ if (!depthCheck.allowed) {
145
+ return {
146
+ content: [{ type: 'text', text: `Blocked: ${depthCheck.reason}` }],
147
+ details: { blocked: true, reason: depthCheck.reason },
148
+ isError: true,
149
+ };
150
+ }
151
+
152
+ const callCheck = checkCallGuard();
153
+ if (!callCheck.allowed) {
154
+ return {
155
+ content: [{ type: 'text', text: `Blocked: ${callCheck.reason}` }],
156
+ details: { blocked: true, reason: callCheck.reason },
157
+ isError: true,
158
+ };
159
+ }
160
+
161
+ const timeoutCheck = checkTimeoutGuard();
162
+ if (!timeoutCheck.allowed) {
163
+ return {
164
+ content: [{ type: 'text', text: `Blocked: ${timeoutCheck.reason}` }],
165
+ details: { blocked: true, reason: timeoutCheck.reason },
166
+ isError: true,
167
+ };
168
+ }
169
+
170
+ const budgetCheck = checkBudgetGuard();
171
+ if (!budgetCheck.allowed) {
172
+ return {
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text: `Blocked: Budget exceeded ($${loadAccumulatedCost().toFixed(4)} spent)`,
177
+ },
178
+ ],
179
+ details: { blocked: true, reason: 'budget' },
180
+ isError: true,
181
+ };
182
+ }
183
+
184
+ // Execute based on mode
185
+ let results: SubagentResult[];
186
+
187
+ switch (params.mode) {
188
+ case 'single': {
189
+ if (!params.prompt) {
190
+ return {
191
+ content: [{ type: 'text', text: "Missing required 'prompt' for single mode" }],
192
+ details: { error: 'missing prompt' },
193
+ isError: true,
194
+ };
195
+ }
196
+
197
+ let currentData: { output: string; progress?: SubagentProgress } = {
198
+ output: '',
199
+ progress: {
200
+ status: 'running',
201
+ recentOutput: [],
202
+ recentTools: [],
203
+ toolCount: 0,
204
+ tokens: 0,
205
+ durationMs: 0,
206
+ },
207
+ };
208
+ const displayName = formatAgentLabel('subagent', true);
209
+
210
+ const result = await spawnSubagent({
211
+ prompt: params.prompt,
212
+ context: params.context,
213
+ fork: params.fork,
214
+ onUpdate: onUpdate
215
+ ? (data) => {
216
+ currentData = data as { output: string; progress?: SubagentProgress };
217
+ const lines = renderSubagentStatus('subagent', currentData, 100, true);
218
+ onUpdate({
219
+ content: [{ type: 'text', text: lines.join('\n') }],
220
+ details: { progress: data.progress },
221
+ });
222
+ }
223
+ : undefined,
224
+ });
225
+ results = [result];
226
+ break;
227
+ }
228
+
229
+ case 'parallel': {
230
+ if (!params.tasks || params.tasks.length === 0) {
231
+ return {
232
+ content: [{ type: 'text', text: "Missing required 'tasks' array for parallel mode" }],
233
+ details: { error: 'missing tasks' },
234
+ isError: true,
235
+ };
236
+ }
237
+
238
+ const concurrency = params.concurrency || DEFAULTS.CONCURRENCY;
239
+ onUpdate?.({
240
+ content: [
241
+ {
242
+ type: 'text',
243
+ text: `Spawning ${params.tasks.length} subagents (max ${concurrency} concurrent)...`,
244
+ },
245
+ ],
246
+ details: {},
247
+ });
248
+
249
+ // Track progress for each task
250
+ const taskProgress = new Map<string, { output: string; progress?: SubagentProgress }>();
251
+
252
+ // Programmatic parallel spawning — NO LLM involvement between spawns
253
+ results = await runParallel(
254
+ params.tasks,
255
+ async (task) => {
256
+ taskProgress.set(task.id, {
257
+ output: '',
258
+ progress: {
259
+ status: 'running',
260
+ recentOutput: [],
261
+ recentTools: [],
262
+ toolCount: 0,
263
+ tokens: 0,
264
+ durationMs: 0,
265
+ },
266
+ });
267
+
268
+ const result = await spawnSubagent({
269
+ prompt: task.prompt,
270
+ context: task.context,
271
+ timeout: params.timeoutPerTask,
272
+ onUpdate: onUpdate
273
+ ? (data) => {
274
+ taskProgress.set(
275
+ task.id,
276
+ data as { output: string; progress?: SubagentProgress }
277
+ );
278
+ // Render full multi-line status like pi-subagents
279
+ const statusText = renderParallelStatus(taskProgress);
280
+ onUpdate({
281
+ content: [{ type: 'text', text: statusText }],
282
+ details: { taskProgress: Object.fromEntries(taskProgress) },
283
+ });
284
+ }
285
+ : undefined,
286
+ });
287
+
288
+ // IMPORTANT: Update with final result so status shows completed/failed
289
+ if (onUpdate) {
290
+ taskProgress.set(task.id, {
291
+ output: result.output,
292
+ progress: {
293
+ status: result.success ? 'completed' : 'failed',
294
+ recentOutput: result.progress?.recentOutput || [],
295
+ recentTools: result.progress?.recentTools || [],
296
+ toolCount: result.progress?.toolCount || 0,
297
+ tokens: (result.usage?.input || 0) + (result.usage?.output || 0),
298
+ durationMs: result.durationMs,
299
+ },
300
+ });
301
+ const statusText = renderParallelStatus(taskProgress);
302
+ onUpdate({
303
+ content: [{ type: 'text', text: statusText }],
304
+ details: { taskProgress: Object.fromEntries(taskProgress) },
305
+ });
306
+ }
307
+
308
+ return result;
309
+ },
310
+ concurrency
311
+ );
312
+ break;
313
+ }
314
+
315
+ case 'chain': {
316
+ if (!params.chain || params.chain.length === 0) {
317
+ return {
318
+ content: [{ type: 'text', text: "Missing required 'chain' array for chain mode" }],
319
+ details: { error: 'missing chain' },
320
+ isError: true,
321
+ };
322
+ }
323
+
324
+ results = [];
325
+ let previousOutput = '';
326
+
327
+ for (const step of params.chain) {
328
+ // Check cancellation
329
+ if (signal?.aborted) {
330
+ results.push({
331
+ id: step.id,
332
+ success: false,
333
+ output: '',
334
+ error: 'Cancelled',
335
+ durationMs: 0,
336
+ });
337
+ break;
338
+ }
339
+
340
+ // Substitute {previous} placeholder
341
+ const prompt = step.prompt.replace(/\{previous\}/g, previousOutput);
342
+
343
+ let stepData: { output: string; progress?: SubagentProgress } = {
344
+ output: '',
345
+ progress: {
346
+ status: 'running',
347
+ recentOutput: [],
348
+ recentTools: [],
349
+ toolCount: 0,
350
+ tokens: 0,
351
+ durationMs: 0,
352
+ },
353
+ };
354
+ const stepLabel = formatAgentLabel(step.id, true);
355
+
356
+ const result = await spawnSubagent({
357
+ prompt,
358
+ onUpdate: onUpdate
359
+ ? (data) => {
360
+ stepData = data as { output: string; progress?: SubagentProgress };
361
+ const lines = renderSubagentStatus(step.id, stepData, 100, true);
362
+ // Show step in context of chain
363
+ const header = `Step ${results.length + 1}/${params.chain!.length}: ${stepLabel}`;
364
+ onUpdate({
365
+ content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }],
366
+ details: {
367
+ stepId: step.id,
368
+ stepIndex: results.length + 1,
369
+ totalSteps: params.chain!.length,
370
+ stepProgress: data.progress,
371
+ },
372
+ });
373
+ }
374
+ : undefined,
375
+ });
376
+ results.push(result);
377
+
378
+ if (!result.success) {
379
+ break; // Stop chain on failure
380
+ }
381
+
382
+ previousOutput = result.output;
383
+ }
384
+ break;
385
+ }
386
+
387
+ default:
388
+ return {
389
+ content: [{ type: 'text', text: `Unknown mode: ${(params as any).mode}` }],
390
+ details: { error: 'unknown mode' },
391
+ isError: true,
392
+ };
393
+ }
394
+
395
+ // Calculate aggregated stats
396
+ const succeeded = results.filter((r) => r.success).length;
397
+ const totalCost = results.reduce((sum, r) => sum + (r.usage?.cost || 0), 0);
398
+
399
+ // Update accumulated cost
400
+ if (totalCost > 0) {
401
+ const currentCost = loadAccumulatedCost();
402
+ saveAccumulatedCost(currentCost + totalCost);
403
+ }
404
+
405
+ const totalDuration = Date.now() - startTime;
406
+ const invocationId = Math.random().toString(36).substring(2, 10);
407
+
408
+ const result: RecurseResult = {
409
+ results,
410
+ stats: {
411
+ total: results.length,
412
+ succeeded,
413
+ failed: results.length - succeeded,
414
+ totalDurationMs: totalDuration,
415
+ totalCost,
416
+ },
417
+ depth: currentDepth,
418
+ mode: params.mode,
419
+ invocationId,
420
+ };
421
+
422
+ // Format output
423
+ const lines: string[] = [
424
+ `## Recurse Results (depth ${currentDepth})`,
425
+ '',
426
+ `**Stats:** ${result.stats.succeeded}/${result.stats.total} succeeded · ${(result.stats.totalDurationMs / 1000).toFixed(1)}s${totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : ''}`,
427
+ '',
428
+ ];
429
+
430
+ for (const r of results) {
431
+ const icon = r.success ? '✓' : '✗';
432
+ const duration = (r.durationMs / 1000).toFixed(1);
433
+ const stopReason = r.stopReason && r.stopReason !== 'completed' ? ` [${r.stopReason}]` : '';
434
+ lines.push(`### ${icon} ${r.id} (${duration}s)${stopReason}`);
435
+ if (r.error) {
436
+ lines.push(`**Error:** ${r.error}`);
437
+ }
438
+ lines.push(r.output || '*(no output)*');
439
+ lines.push('');
440
+ }
441
+
442
+ return {
443
+ content: [{ type: 'text', text: lines.join('\n') }],
444
+ details: result,
445
+ };
446
+ },
447
+
448
+ // Custom rendering
449
+ renderCall(args, theme) {
450
+ const mode = (args.mode as string) || 'single';
451
+ const modeLabel = theme.fg('accent', mode);
452
+
453
+ let detail = '';
454
+ if (mode === 'single' && args.prompt) {
455
+ const prompt = String(args.prompt).slice(0, 40);
456
+ detail = prompt.length < String(args.prompt).length ? `${prompt}...` : prompt;
457
+ } else if (mode === 'parallel' && args.tasks) {
458
+ detail = `${(args.tasks as any[]).length} tasks`;
459
+ } else if (mode === 'chain' && args.chain) {
460
+ detail = `${(args.chain as any[]).length} steps`;
461
+ }
462
+
463
+ const text =
464
+ theme.fg('toolTitle', 'recurse ') +
465
+ modeLabel +
466
+ (detail ? theme.fg('dim', ` "${detail}"`) : '');
467
+
468
+ return new Text(text, 0, 0);
469
+ },
470
+
471
+ renderResult(result, { expanded }, theme) {
472
+ const data = result.details as RecurseResult | undefined;
473
+ if (!data) {
474
+ return new Text(theme.fg('dim', 'No result data'), 0, 0);
475
+ }
476
+
477
+ const { stats, depth, mode } = data;
478
+ const icon = stats.failed === 0 ? theme.fg('success', '✓') : theme.fg('warning', '⚠');
479
+ const hasChildren = data.results.some((r) => r.children);
480
+
481
+ let text = `${icon} ${stats.succeeded}/${stats.total} at depth ${depth}${mode ? ` · ${mode}` : ''}`;
482
+
483
+ if (stats.totalCost && stats.totalCost > 0) {
484
+ text += theme.fg('dim', ` · $${stats.totalCost.toFixed(4)}`);
485
+ }
486
+
487
+ if (hasChildren) {
488
+ text += theme.fg('accent', ' [has children]');
489
+ }
490
+
491
+ if (expanded) {
492
+ if (hasChildren && mode) {
493
+ // Render tree view
494
+ const tree = buildRecurseTree(data, mode);
495
+ const treeLines = renderRecurseTree(tree, 100);
496
+ text += '\n' + treeLines.join('\n');
497
+ } else {
498
+ // Simple flat view
499
+ text += '\n';
500
+ for (const r of data.results) {
501
+ const status = r.success ? theme.fg('success', '✓') : theme.fg('error', '✗');
502
+ text += ` ${status} ${r.id}`;
503
+ if (r.children) {
504
+ text += theme.fg('accent', ` → ${r.children.stats.total} children`);
505
+ }
506
+ text += '\n';
507
+ }
508
+ }
509
+ }
510
+
511
+ return new Text(text, 0, 0);
512
+ },
513
+ });
514
+
515
+ pi.registerCommand('recurse-status', {
516
+ description: 'Show current recursion status and guardrails',
517
+ handler: async (_args, ctx) => {
518
+ const depth = getCurrentDepth();
519
+ const max = getMaxDepth();
520
+ const budget = checkBudgetGuard();
521
+
522
+ const lines = [
523
+ '## Recurse Status',
524
+ '',
525
+ `**Current depth:** ${depth} / ${max}`,
526
+ `**Tool enabled:** ${toolEnabled}`,
527
+ `**Budget:** ${budget.remaining === Infinity ? 'unlimited' : `$${budget.remaining.toFixed(2)} remaining`}`,
528
+ `**Trace ID:** ${getTraceId()}`,
529
+ ];
530
+
531
+ ctx.ui.notify(lines.join('\n'), 'info');
532
+ },
533
+ });
534
+
535
+ function StringEnum<T extends readonly string[]>(
536
+ values: T,
537
+ options?: { description?: string; default?: T[number] }
538
+ ) {
539
+ return Type.Unsafe<T[number]>({
540
+ type: 'string',
541
+ enum: [...values],
542
+ ...(options?.description && { description: options.description }),
543
+ ...(options?.default && { default: options.default }),
544
+ });
545
+ }
546
+ }