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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/formatters.ts +295 -0
- package/index.ts +546 -0
- package/lib.ts +854 -0
- package/names.ts +62 -0
- package/package.json +88 -0
- package/tests/lib.test.ts +214 -0
- package/types.ts +173 -0
- package/vitest.config.ts +9 -0
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
|
+
}
|