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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-03-24
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial implementation of `pi-recurse` extension
|
|
12
|
+
- Three execution modes: `single`, `parallel`, and `chain`
|
|
13
|
+
- Programmatic parallel spawning (no LLM involvement in loop)
|
|
14
|
+
- Depth-based guardrails (RLM_MAX_DEPTH, RLM_DEPTH)
|
|
15
|
+
- Call count tracking (RLM_MAX_CALLS)
|
|
16
|
+
- Timeout enforcement (RLM_TIMEOUT)
|
|
17
|
+
- Budget tracking (RLM_BUDGET)
|
|
18
|
+
- Tool disabling at configurable depth threshold
|
|
19
|
+
- System prompt injection via `before_agent_start`
|
|
20
|
+
- Status bar indicator showing current depth
|
|
21
|
+
- Custom tool rendering for TUI
|
|
22
|
+
- `/recurse-status` command for debugging
|
|
23
|
+
- Comprehensive test suite
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tom X Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🔁 pi-recurse
|
|
4
|
+
|
|
5
|
+
**Programmatic recursive subagent spawning for [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_LLM makes ONE tool call, extension code handles parallel spawning and result aggregation._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
This project is the spiritual successor to **[ypi](https://github.com/rawwerks/ypi)** by [@rawwerks](https://github.com/rawwerks), originally created as a bash wrapper around Pi that enabled recursive LLM calls with guardrails.
|
|
17
|
+
|
|
18
|
+
**Why the conversion?** The original `ypi` proved that recursive subagents could work effectively, but required the LLM to generate bash scripts for parallelization. By converting to a proper Pi extension, `pi-recurse` achieves:
|
|
19
|
+
|
|
20
|
+
- **True programmatic looping** — parallel execution happens in TypeScript code, not bash generated by the LLM
|
|
21
|
+
- **Better integration** — hot-reload with `/reload`, automatic tool discovery, native Pi packaging
|
|
22
|
+
- **Structured results** — full TypeScript types instead of bash string manipulation
|
|
23
|
+
- **Single entry point** — just `pi`, no wrapper scripts needed
|
|
24
|
+
|
|
25
|
+
Thank you to the original `ypi` for pioneering the concept of depth-limited recursive LLM calls in a coding agent context.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### Via Git URL (Recommended for now)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install https://github.com/monotykamary/pi-recurse
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Via npm (when published)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pi install pi-recurse
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Local Development
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/monotykamary/pi-recurse.git
|
|
47
|
+
cd pi-recurse
|
|
48
|
+
pi install .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The extension will be auto-discovered by Pi on next start, or use `/reload` to load it immediately.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
The extension adds a `recurse` tool to Pi, enabling three execution modes:
|
|
58
|
+
|
|
59
|
+
### Single Mode — One-off delegation
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
recurse({
|
|
63
|
+
mode: 'single',
|
|
64
|
+
prompt: 'Analyze the error handling in src/auth.ts',
|
|
65
|
+
context: fileContent, // optional context to pipe
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Parallel Mode — Batch processing
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
recurse({
|
|
73
|
+
mode: 'parallel',
|
|
74
|
+
tasks: [
|
|
75
|
+
{ id: 'auth', prompt: 'Review auth module' },
|
|
76
|
+
{ id: 'db', prompt: 'Review database layer' },
|
|
77
|
+
{ id: 'api', prompt: 'Review API routes' },
|
|
78
|
+
],
|
|
79
|
+
concurrency: 3,
|
|
80
|
+
timeoutPerTask: 120,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Key advantage:** All 3 subagents spawn from a **single LLM tool call**. The extension handles `Promise.all()` style concurrency internally — no autoregressive steps between spawns.
|
|
85
|
+
|
|
86
|
+
### Chain Mode — Sequential dependencies
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
recurse({
|
|
90
|
+
mode: 'chain',
|
|
91
|
+
chain: [
|
|
92
|
+
{ id: 'read', prompt: 'Read README.md and summarize' },
|
|
93
|
+
{ id: 'analyze', prompt: 'Given this summary: {previous} — identify risks' },
|
|
94
|
+
{ id: 'plan', prompt: 'Given these risks: {previous} — suggest mitigations' },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Guardrails
|
|
102
|
+
|
|
103
|
+
Environment variables control recursion limits across the entire call tree:
|
|
104
|
+
|
|
105
|
+
| Variable | Default | Description |
|
|
106
|
+
| --------------------- | ------- | ------------------------------------ |
|
|
107
|
+
| `RLM_MAX_DEPTH` | 3 | Maximum recursion depth (0 = root) |
|
|
108
|
+
| `RLM_MAX_CALLS` | 100 | Maximum total recurse invocations |
|
|
109
|
+
| `RLM_TIMEOUT` | 600 | Wall-clock seconds for entire tree |
|
|
110
|
+
| `RLM_BUDGET` | — | Max dollar spend (e.g., `0.50`) |
|
|
111
|
+
| `RLM_CHILD_MODEL` | — | Model override for depth > 0 |
|
|
112
|
+
| `RLM_DISABLE_TOOL_AT` | 3 | Disable recurse tool at this depth |
|
|
113
|
+
| `RLM_TRACE_ID` | auto | Links all sessions in recursive tree |
|
|
114
|
+
|
|
115
|
+
At deep depths (≥ `RLM_DISABLE_TOOL_AT`), the recurse tool automatically disables itself and guides agents to work directly instead of delegating.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Architecture: From Bash to Extension
|
|
120
|
+
|
|
121
|
+
### Original ypi (Bash Wrapper)
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
ypi "analyze this codebase"
|
|
125
|
+
↓
|
|
126
|
+
Launcher sets env, execs pi with custom system prompt
|
|
127
|
+
↓
|
|
128
|
+
LLM generates bash loop for parallel tasks:
|
|
129
|
+
for f in $(find src -name "*.ts"); do
|
|
130
|
+
rlm_query --async "Review $f"
|
|
131
|
+
done
|
|
132
|
+
↓
|
|
133
|
+
Wait on sentinel files, aggregate in bash
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Limitation:** The LLM had to generate the loop structure, and parallelization required autoregressive generation of bash code.
|
|
137
|
+
|
|
138
|
+
### pi-recurse (Pi Extension)
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
pi (with pi-recurse extension loaded)
|
|
142
|
+
↓
|
|
143
|
+
LLM calls once: recurse({mode: "parallel", tasks: [...]})
|
|
144
|
+
↓
|
|
145
|
+
Extension execute() runs in Node.js:
|
|
146
|
+
├── Check guardrails (depth, budget, timeout)
|
|
147
|
+
├── runParallel(tasks, spawnFn, concurrency)
|
|
148
|
+
│ └── Promise.all(concurrent batches)
|
|
149
|
+
├── Aggregate structured results
|
|
150
|
+
└── Return {results, stats, depth}
|
|
151
|
+
↓
|
|
152
|
+
LLM receives aggregated data, continues reasoning
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Advantage:** Parallel execution happens in **code**, not in LLM-generated bash. One tool call → many subagents → aggregated result.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Comparison: ypi vs pi-recurse
|
|
160
|
+
|
|
161
|
+
| Feature | ypi (Wrapper) | pi-recurse (Extension) |
|
|
162
|
+
| --------------------- | ------------------------ | --------------------------------- |
|
|
163
|
+
| Entry point | `ypi "prompt"` | `pi` (extension auto-loaded) |
|
|
164
|
+
| Parallel spawning | LLM writes bash loop | **Code executes `Promise.all()`** |
|
|
165
|
+
| Result aggregation | Bash string manipulation | TypeScript structured objects |
|
|
166
|
+
| Hot reload | Restart ypi process | `/reload` in pi |
|
|
167
|
+
| Distribution | npm global bin | `pi install` / git URL |
|
|
168
|
+
| Extension integration | Manual env/PATH setup | Native auto-discovery |
|
|
169
|
+
| Tool disabling | Manual depth checks | Automatic at configured depth |
|
|
170
|
+
| Status visibility | Footer via ypi.ts | Native Pi status bar |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Clone and install dependencies
|
|
178
|
+
git clone https://github.com/monotykamary/pi-recurse.git
|
|
179
|
+
cd pi-recurse
|
|
180
|
+
npm install
|
|
181
|
+
|
|
182
|
+
# Run tests
|
|
183
|
+
npm test
|
|
184
|
+
|
|
185
|
+
# Type check
|
|
186
|
+
npm run typecheck
|
|
187
|
+
|
|
188
|
+
# Build (optional - jiti handles TypeScript at runtime)
|
|
189
|
+
npm run build
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Testing the Extension
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# Install locally
|
|
196
|
+
pi install .
|
|
197
|
+
|
|
198
|
+
# Or test without installing
|
|
199
|
+
pi -e ./index.ts
|
|
200
|
+
|
|
201
|
+
# In pi, test recursion
|
|
202
|
+
/recurse-status
|
|
203
|
+
recurse({mode: "single", prompt: "What is 2+2?"})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Design Principles
|
|
209
|
+
|
|
210
|
+
1. **Single tool call, parallel execution** — The LLM describes intent; code handles concurrency
|
|
211
|
+
2. **Depth-aware degradation** — At deep levels, disable recursion to prevent runaway costs
|
|
212
|
+
3. **Structured over stringly** — Full TypeScript types for inputs and outputs
|
|
213
|
+
4. **Guardrails in code** — Budget, timeout, depth checked programmatically
|
|
214
|
+
5. **Pi-native integration** — Use Pi's extension API, not wrapper scripts
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Related Projects
|
|
219
|
+
|
|
220
|
+
- **[ypi](https://github.com/rawwerks/ypi)** — The original bash wrapper that inspired this extension
|
|
221
|
+
- **[pi-messenger](https://github.com/monotykamary/pi-messenger-swarm)** — Multi-agent coordination for Pi (another extension)
|
|
222
|
+
- **[pi](https://github.com/badlogic/pi-mono)** — The Pi coding agent itself
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT — See [LICENSE](LICENSE) for details.
|
package/formatters.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting helpers for subagent progress display
|
|
3
|
+
* Following pi-subagents patterns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format token count with k/M suffixes
|
|
8
|
+
*/
|
|
9
|
+
export function formatTokens(tokens: number): string {
|
|
10
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
11
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
12
|
+
return `${tokens}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format duration in ms to human readable
|
|
17
|
+
*/
|
|
18
|
+
export function formatDuration(ms: number): string {
|
|
19
|
+
if (ms < 1000) return `${ms}ms`;
|
|
20
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
21
|
+
const mins = Math.floor(ms / 60_000);
|
|
22
|
+
const secs = ((ms % 60_000) / 1000).toFixed(0);
|
|
23
|
+
return `${mins}m${secs}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format usage info line
|
|
28
|
+
*/
|
|
29
|
+
export function formatUsage(usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: number }): string {
|
|
30
|
+
if (!usage) return "";
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
if (usage.input || usage.output) {
|
|
33
|
+
parts.push(`${formatTokens(usage.input || 0)}↑ ${formatTokens(usage.output || 0)}↓`);
|
|
34
|
+
}
|
|
35
|
+
if (usage.cacheRead) parts.push(`${formatTokens(usage.cacheRead)}↺`);
|
|
36
|
+
if (usage.cacheWrite) parts.push(`${formatTokens(usage.cacheWrite)}↻`);
|
|
37
|
+
if (usage.cost !== undefined && usage.cost > 0) {
|
|
38
|
+
parts.push(`$${usage.cost.toFixed(4)}`);
|
|
39
|
+
}
|
|
40
|
+
return parts.join(" · ") || "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
import { formatAgentLabel } from "./names.js";
|
|
44
|
+
import type { RecurseResult, RecurseTreeNode, SubagentResult } from "./types.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Truncate text to max length with ellipsis
|
|
48
|
+
* Uses Intl.Segmenter for proper Unicode handling
|
|
49
|
+
*/
|
|
50
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
51
|
+
|
|
52
|
+
export function truncLine(text: string, maxWidth: number): string {
|
|
53
|
+
if (text.length <= maxWidth) return text;
|
|
54
|
+
|
|
55
|
+
let result = "";
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const seg of segmenter.segment(text)) {
|
|
58
|
+
if (count >= maxWidth - 1) {
|
|
59
|
+
return result + "…";
|
|
60
|
+
}
|
|
61
|
+
result += seg.segment;
|
|
62
|
+
count++;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get status icon - uses distinct Unicode symbols instead of confusing "..."
|
|
69
|
+
*/
|
|
70
|
+
export function getStatusIcon(status: "running" | "completed" | "failed" | undefined): string {
|
|
71
|
+
switch (status) {
|
|
72
|
+
case "running": return "▶"; // Play icon - clearly indicates active
|
|
73
|
+
case "completed": return "✓"; // Checkmark
|
|
74
|
+
case "failed": return "✗"; // X mark
|
|
75
|
+
default: return "○"; // Circle for pending/unknown
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a tree structure from a RecurseResult for visualization
|
|
81
|
+
*/
|
|
82
|
+
export function buildRecurseTree(result: RecurseResult, mode: "single" | "parallel" | "chain", parentId?: string): RecurseTreeNode {
|
|
83
|
+
const invocationId = result.invocationId || Math.random().toString(36).substring(2, 8);
|
|
84
|
+
|
|
85
|
+
const node: RecurseTreeNode = {
|
|
86
|
+
id: invocationId,
|
|
87
|
+
mode,
|
|
88
|
+
depth: result.depth,
|
|
89
|
+
status: result.stats.failed === 0 ? "completed" : (result.stats.failed < result.stats.total ? "running" : "failed"),
|
|
90
|
+
stats: result.stats,
|
|
91
|
+
children: [],
|
|
92
|
+
parentId,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Build child nodes from subagent results that have their own recurse calls
|
|
96
|
+
for (const subagent of result.results) {
|
|
97
|
+
if (subagent.children) {
|
|
98
|
+
const childNode = buildRecurseTree(subagent.children, subagent.children.mode || "single", invocationId);
|
|
99
|
+
node.children.push(childNode);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return node;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render a recurse tree with ASCII/Unicode tree drawing characters
|
|
108
|
+
*/
|
|
109
|
+
export function renderRecurseTree(
|
|
110
|
+
node: RecurseTreeNode,
|
|
111
|
+
maxWidth: number = 100,
|
|
112
|
+
prefix: string = "",
|
|
113
|
+
isLast: boolean = true,
|
|
114
|
+
isRoot: boolean = true
|
|
115
|
+
): string[] {
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
|
|
118
|
+
// Build the status line
|
|
119
|
+
const icon = getStatusIcon(node.status);
|
|
120
|
+
const modeLabel = node.mode;
|
|
121
|
+
const stats = `${node.stats.succeeded}/${node.stats.total}`;
|
|
122
|
+
const cost = node.stats.totalCost ? ` · $${node.stats.totalCost.toFixed(4)}` : "";
|
|
123
|
+
const duration = formatDuration(node.stats.totalDurationMs);
|
|
124
|
+
|
|
125
|
+
// Tree drawing characters
|
|
126
|
+
const branch = isRoot ? "" : (isLast ? "└─ " : "├─ ");
|
|
127
|
+
const indent = isRoot ? "" : prefix;
|
|
128
|
+
|
|
129
|
+
const line = `${indent}${branch}${icon} ${modeLabel} [${stats}]${cost} · ${duration} (depth ${node.depth})`;
|
|
130
|
+
lines.push(truncLine(line, maxWidth));
|
|
131
|
+
|
|
132
|
+
// Render children
|
|
133
|
+
if (node.children.length > 0) {
|
|
134
|
+
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
137
|
+
const child = node.children[i];
|
|
138
|
+
const isLastChild = i === node.children.length - 1;
|
|
139
|
+
const childLines = renderRecurseTree(child, maxWidth, childPrefix, isLastChild, false);
|
|
140
|
+
lines.push(...childLines);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format a flat list of recurse results into a forest (multiple trees)
|
|
149
|
+
*/
|
|
150
|
+
export function renderRecurseForest(
|
|
151
|
+
results: RecurseResult[],
|
|
152
|
+
modes: ("single" | "parallel" | "chain")[],
|
|
153
|
+
maxWidth: number = 100
|
|
154
|
+
): string {
|
|
155
|
+
const allLines: string[] = [];
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < results.length; i++) {
|
|
158
|
+
const tree = buildRecurseTree(results[i], modes[i]);
|
|
159
|
+
const lines = renderRecurseTree(tree, maxWidth);
|
|
160
|
+
allLines.push(...lines);
|
|
161
|
+
|
|
162
|
+
// Add separator between trees
|
|
163
|
+
if (i < results.length - 1) {
|
|
164
|
+
allLines.push("");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return allLines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Count total nodes in a recurse tree (for stats)
|
|
173
|
+
*/
|
|
174
|
+
export function countTreeNodes(node: RecurseTreeNode): number {
|
|
175
|
+
let count = 1; // This node
|
|
176
|
+
for (const child of node.children) {
|
|
177
|
+
count += countTreeNodes(child);
|
|
178
|
+
}
|
|
179
|
+
return count;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find the deepest depth in a recurse tree
|
|
184
|
+
*/
|
|
185
|
+
export function getTreeMaxDepth(node: RecurseTreeNode): number {
|
|
186
|
+
if (node.children.length === 0) {
|
|
187
|
+
return node.depth;
|
|
188
|
+
}
|
|
189
|
+
let maxChildDepth = node.depth;
|
|
190
|
+
for (const child of node.children) {
|
|
191
|
+
maxChildDepth = Math.max(maxChildDepth, getTreeMaxDepth(child));
|
|
192
|
+
}
|
|
193
|
+
return maxChildDepth;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Render subagent progress like pi-subagents
|
|
198
|
+
*/
|
|
199
|
+
export interface ProgressData {
|
|
200
|
+
status: "running" | "completed" | "failed";
|
|
201
|
+
currentTool?: string;
|
|
202
|
+
currentToolArgs?: string;
|
|
203
|
+
recentTools?: Array<{ tool: string; args: string; endMs?: number }>;
|
|
204
|
+
recentOutput?: string[];
|
|
205
|
+
toolCount: number;
|
|
206
|
+
tokens: number;
|
|
207
|
+
durationMs: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format progress line: "... | 5 tools, 12.3k tok, 2.4s"
|
|
212
|
+
*/
|
|
213
|
+
export function formatProgressLine(data: ProgressData): string {
|
|
214
|
+
const parts: string[] = [];
|
|
215
|
+
if (data.toolCount > 0) parts.push(`${data.toolCount} tools`);
|
|
216
|
+
if (data.tokens > 0) parts.push(`${formatTokens(data.tokens)} tok`);
|
|
217
|
+
if (data.durationMs > 0) parts.push(`${formatDuration(data.durationMs)}`);
|
|
218
|
+
return parts.join(", ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render full subagent status with multi-line output
|
|
223
|
+
* Mimics pi-subagents render.ts output
|
|
224
|
+
*/
|
|
225
|
+
export function renderSubagentStatus(
|
|
226
|
+
id: string,
|
|
227
|
+
data: { output: string; progress?: ProgressData },
|
|
228
|
+
maxWidth: number = 100,
|
|
229
|
+
useHumanizedName: boolean = true
|
|
230
|
+
): string[] {
|
|
231
|
+
const lines: string[] = [];
|
|
232
|
+
const p = data.progress;
|
|
233
|
+
|
|
234
|
+
// Use humanized name for display
|
|
235
|
+
const displayName = formatAgentLabel(id, useHumanizedName);
|
|
236
|
+
|
|
237
|
+
// Status line: "▶ swift-fox (package.json) | 5 tools, 12.3k tok, 2.4s"
|
|
238
|
+
const icon = getStatusIcon(p?.status);
|
|
239
|
+
const metrics = p ? formatProgressLine(p) : "";
|
|
240
|
+
const header = metrics ? `${icon} ${displayName} | ${metrics}` : `${icon} ${displayName}`;
|
|
241
|
+
lines.push(truncLine(header, maxWidth));
|
|
242
|
+
|
|
243
|
+
if (p?.status === "running") {
|
|
244
|
+
// Current tool line: "> read: path: "file.ts"..."
|
|
245
|
+
if (p.currentTool) {
|
|
246
|
+
const args = p.currentToolArgs || "";
|
|
247
|
+
const toolLine = args ? `> ${p.currentTool}: ${truncLine(args, maxWidth - 20)}` : `> ${p.currentTool}`;
|
|
248
|
+
lines.push(toolLine);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Recent tools (last 3)
|
|
252
|
+
if (p.recentTools?.length) {
|
|
253
|
+
for (const t of p.recentTools.slice(-3)) {
|
|
254
|
+
const argsPreview = truncLine(t.args, maxWidth - 30);
|
|
255
|
+
lines.push(` ${t.tool}: ${argsPreview}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Recent output (last 5 lines)
|
|
260
|
+
if (p.recentOutput?.length) {
|
|
261
|
+
for (const line of p.recentOutput.slice(-5)) {
|
|
262
|
+
lines.push(` ${truncLine(line, maxWidth - 4)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If no progress detail but have output, show output preview
|
|
268
|
+
if ((!p || p.status !== "running") && data.output) {
|
|
269
|
+
const outputLines = data.output.split("\n").filter(l => l.trim()).slice(-3);
|
|
270
|
+
for (const line of outputLines) {
|
|
271
|
+
lines.push(` ${truncLine(line, maxWidth - 4)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return lines;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Render parallel task status list
|
|
280
|
+
*/
|
|
281
|
+
export function renderParallelStatus(
|
|
282
|
+
taskProgress: Map<string, { output: string; progress?: ProgressData }>,
|
|
283
|
+
maxWidth: number = 100,
|
|
284
|
+
useHumanizedNames: boolean = true
|
|
285
|
+
): string {
|
|
286
|
+
const allLines: string[] = [];
|
|
287
|
+
|
|
288
|
+
for (const [id, data] of taskProgress) {
|
|
289
|
+
const taskLines = renderSubagentStatus(id, data, maxWidth, useHumanizedNames);
|
|
290
|
+
allLines.push(...taskLines);
|
|
291
|
+
allLines.push(""); // Blank line between tasks
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return allLines.join("\n").trim();
|
|
295
|
+
}
|