@jerryan/pi-subagent-tools 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/LICENSE +21 -0
- package/README.md +70 -0
- package/index.ts +428 -0
- package/package.json +53 -0
- package/prompts/explore.md +4 -0
- package/prompts/review.md +4 -0
- package/spawn.ts +262 -0
- package/tui.ts +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
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,70 @@
|
|
|
1
|
+
# @jerryan/pi-subagent-tools
|
|
2
|
+
|
|
3
|
+
A minimal pi extension for delegating work to isolated subagent processes. Three tools, one job each. The agent is a **project manager** — delegate by default, dive in only when necessary.
|
|
4
|
+
|
|
5
|
+
## What makes this different?
|
|
6
|
+
|
|
7
|
+
Every subagent is a fresh `pi` process with an isolated context window. No agent definitions to write, no configuration files to maintain, no mode-switching parameters to misconfigure. Each tool's name *is* the contract:
|
|
8
|
+
|
|
9
|
+
- **`review`** — always read-only, always in the current project. You can't forget to lock it down.
|
|
10
|
+
- **`explore`** — always read-only, requires a target directory. Picks up the target project's settings, skills, and context.
|
|
11
|
+
- **`delegate`** — general-purpose worker. Inherits the parent's tools and system prompt for optimal cache reuse. Optional CWD and skills.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@jerryan/pi-subagent-tools
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The extension is available the next time you start a pi session.
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
### `review`
|
|
24
|
+
|
|
25
|
+
Review code, diffs, or files in the current project. The reviewer is always read-only — it cannot modify files or execute commands.
|
|
26
|
+
|
|
27
|
+
| Parameter | Type | Required | Description |
|
|
28
|
+
|-----------|------|----------|-------------|
|
|
29
|
+
| `task` | string | Yes | What to review |
|
|
30
|
+
| `skills` | string[] | No | Skills to load via `--skill` |
|
|
31
|
+
|
|
32
|
+
### `explore`
|
|
33
|
+
|
|
34
|
+
Map a project directory. The explorer runs in the target directory, picking up its `.pi/settings.json`, skills, AGENTS.md, and other context files. Always read-only.
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Required | Description |
|
|
37
|
+
|-----------|------|----------|-------------|
|
|
38
|
+
| `task` | string | Yes | What to explore |
|
|
39
|
+
| `cwd` | string | Yes | Target project directory |
|
|
40
|
+
| `skills` | string[] | No | Skills to load via `--skill` |
|
|
41
|
+
|
|
42
|
+
### `delegate`
|
|
43
|
+
|
|
44
|
+
General-purpose worker. No tool filtering, no system prompt override — the subagent inherits the parent's full environment, enabling cache hits when used with context inheritance (future).
|
|
45
|
+
|
|
46
|
+
| Parameter | Type | Required | Description |
|
|
47
|
+
|-----------|------|----------|-------------|
|
|
48
|
+
| `task` | string | Yes | Task to delegate |
|
|
49
|
+
| `cwd` | string | No | Working directory (defaults to parent's CWD) |
|
|
50
|
+
| `skills` | string[] | No | Skills to load via `--skill` |
|
|
51
|
+
| `context` | `"fresh"` \| `"inherit"` | No | Context mode (not yet implemented) |
|
|
52
|
+
|
|
53
|
+
## Recursion guard
|
|
54
|
+
|
|
55
|
+
Subagents cannot spawn further subagents beyond one level:
|
|
56
|
+
|
|
57
|
+
| Parent role | `review` | `explore` | `delegate` |
|
|
58
|
+
|-------------|:--:|:--:|:--:|
|
|
59
|
+
| Parent (normal) | ✓ | ✓ | ✓ |
|
|
60
|
+
| Delegate child | ✓ | ✓ | Rejects at runtime |
|
|
61
|
+
| Review / explore child | — | — | — |
|
|
62
|
+
|
|
63
|
+
## Related
|
|
64
|
+
|
|
65
|
+
- **[pi-subagents](https://github.com/nicobailon/pi-subagents)** — Feature-rich implementation with agent definitions, background runs, session forking, inter-process communication, and a full management UI. The definitive reference for what's possible, though possibly more than most workflows need.
|
|
66
|
+
- **[pi-subagent-lite](https://github.com/JerryAZR/pi-subagent-lite)** — Ultra-minimal (~250 lines). A single `task` tool with minimal context overhead.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagent-tools — Specialized subagent delegation for pi
|
|
3
|
+
*
|
|
4
|
+
* Registers three tools for the "agent as project manager" workflow:
|
|
5
|
+
* review — Read-only code review in the current project
|
|
6
|
+
* explore — Read-only exploration of external projects (requires cwd)
|
|
7
|
+
* delegate — General-purpose worker with optional readonly and context inheritance
|
|
8
|
+
*
|
|
9
|
+
* Recursion guard via PI_SUBAGENT_TOOLS_ROLE env var:
|
|
10
|
+
* - Parent (unset): all three tools active
|
|
11
|
+
* - Delegate child: review/explore active, delegate registered but rejects at runtime
|
|
12
|
+
* - Review/explore child: no subagent tools registered
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Container, Spacer, Text, Markdown } from "@earendil-works/pi-tui";
|
|
22
|
+
import { Type } from "@sinclair/typebox";
|
|
23
|
+
import {
|
|
24
|
+
runSubagent,
|
|
25
|
+
READONLY_TOOLS,
|
|
26
|
+
} from "./spawn.ts";
|
|
27
|
+
import { shortenPath, formatUsage, createSpinner } from "./tui.ts";
|
|
28
|
+
|
|
29
|
+
const subagentSpinners = new Map<string, () => string>();
|
|
30
|
+
|
|
31
|
+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Compact preview component — delegates to Text.render(width) for wrapping
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export class CompactPreview {
|
|
38
|
+
private textComp: Text;
|
|
39
|
+
private maxLines: number;
|
|
40
|
+
|
|
41
|
+
constructor(text: string, maxLines: number = 5) {
|
|
42
|
+
this.maxLines = maxLines;
|
|
43
|
+
this.textComp = new Text(text, 0, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(width: number): string[] {
|
|
47
|
+
const lines = this.textComp.render(width);
|
|
48
|
+
if (lines.length === 0) return [];
|
|
49
|
+
|
|
50
|
+
if (lines.length <= this.maxLines) return lines;
|
|
51
|
+
|
|
52
|
+
return ["...", ...lines.slice(-this.maxLines)];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
invalidate() {
|
|
56
|
+
this.textComp.invalidate();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PROMPTS_DIR = path.resolve(EXTENSION_DIR, "prompts");
|
|
61
|
+
const REVIEW_PROMPT_FILE = path.join(PROMPTS_DIR, "review.md");
|
|
62
|
+
const EXPLORE_PROMPT_FILE = path.join(PROMPTS_DIR, "explore.md");
|
|
63
|
+
const EXTENSION_FILE = path.join(EXTENSION_DIR, "index.ts");
|
|
64
|
+
|
|
65
|
+
// Role guard
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export const ROLE_ENV = "PI_SUBAGENT_TOOLS_ROLE";
|
|
69
|
+
|
|
70
|
+
export type SubagentRole = "delegate" | "review" | "explore";
|
|
71
|
+
|
|
72
|
+
export function getCurrentRole(): SubagentRole | undefined {
|
|
73
|
+
const value = process.env[ROLE_ENV];
|
|
74
|
+
if (value === "delegate" || value === "review" || value === "explore") {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Shared parameter types
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export const TaskParam = Type.String({
|
|
85
|
+
description: "Task to delegate to the subagent",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const SkillsParam = Type.Optional(
|
|
89
|
+
Type.Array(Type.String(), {
|
|
90
|
+
description: "Optional startup skills to load via --skill",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
export const CwdParam = Type.Optional(
|
|
95
|
+
Type.String({
|
|
96
|
+
description:
|
|
97
|
+
"Working directory for the subagent. Defaults to the parent's cwd.",
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export const ContextParam = Type.Optional(
|
|
102
|
+
Type.String({
|
|
103
|
+
enum: ["fresh", "inherit"],
|
|
104
|
+
description:
|
|
105
|
+
'Context mode. "fresh" (default): isolated process with no parent history. "inherit": forks parent session so child sees conversation history.',
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Tool-specific parameter schemas
|
|
110
|
+
export const ReviewParams = Type.Object({
|
|
111
|
+
task: TaskParam,
|
|
112
|
+
skills: SkillsParam,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const ExploreParams = Type.Object({
|
|
116
|
+
task: TaskParam,
|
|
117
|
+
cwd: Type.String({
|
|
118
|
+
description:
|
|
119
|
+
"Working directory for the explorer. Required — the explorer runs in the target project to pick up its settings, skills, and context files.",
|
|
120
|
+
}),
|
|
121
|
+
skills: SkillsParam,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const DelegateParams = Type.Object({
|
|
125
|
+
task: TaskParam,
|
|
126
|
+
cwd: CwdParam,
|
|
127
|
+
skills: SkillsParam,
|
|
128
|
+
context: ContextParam,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Shared execute helper
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async function subagentExecute(
|
|
136
|
+
cwd: string,
|
|
137
|
+
task: string,
|
|
138
|
+
skills: string[] | undefined,
|
|
139
|
+
allowedTools: string[] | undefined,
|
|
140
|
+
extensionFile: string | undefined,
|
|
141
|
+
systemPromptFile: string | undefined,
|
|
142
|
+
childRole: "review" | "explore" | "delegate" | undefined,
|
|
143
|
+
signal: AbortSignal | undefined,
|
|
144
|
+
onUpdate: ((result: any) => void) | undefined,
|
|
145
|
+
) {
|
|
146
|
+
// Validate cwd exists before spawning
|
|
147
|
+
if (!cwd) {
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text" as const, text: "Cannot spawn subagent: cwd is required." }],
|
|
150
|
+
isError: true,
|
|
151
|
+
details: { final: true },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const stat = fs.statSync(cwd);
|
|
156
|
+
if (!stat.isDirectory()) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{
|
|
159
|
+
type: "text" as const,
|
|
160
|
+
text: `Cannot spawn subagent: "${cwd}" is not a directory.`,
|
|
161
|
+
}],
|
|
162
|
+
isError: true,
|
|
163
|
+
details: { final: true },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
} catch (err: any) {
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "text" as const,
|
|
170
|
+
text: `Cannot spawn subagent: cwd "${cwd}" does not exist or is not accessible (${err.message}).`,
|
|
171
|
+
}],
|
|
172
|
+
isError: true,
|
|
173
|
+
details: { final: true },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const result = await runSubagent({
|
|
179
|
+
cwd,
|
|
180
|
+
task,
|
|
181
|
+
skills,
|
|
182
|
+
allowedTools,
|
|
183
|
+
extensionFile,
|
|
184
|
+
systemPromptFile,
|
|
185
|
+
childRole,
|
|
186
|
+
signal,
|
|
187
|
+
onUpdate,
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text" as const, text: result.output }],
|
|
191
|
+
isError: result.isError,
|
|
192
|
+
details: {
|
|
193
|
+
usage: {
|
|
194
|
+
turns: result.turns,
|
|
195
|
+
total: result.tokens?.total,
|
|
196
|
+
input: result.tokens?.input,
|
|
197
|
+
output: result.tokens?.output,
|
|
198
|
+
durationMs: result.durationMs,
|
|
199
|
+
},
|
|
200
|
+
final: true,
|
|
201
|
+
stderr: result.stderr,
|
|
202
|
+
},
|
|
203
|
+
toolCalls: result.toolCalls,
|
|
204
|
+
};
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
return {
|
|
207
|
+
content: [{
|
|
208
|
+
type: "text" as const,
|
|
209
|
+
text: `Subagent failed: ${err.message || String(err)}`,
|
|
210
|
+
}],
|
|
211
|
+
isError: true,
|
|
212
|
+
details: { final: true },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Shared render helpers
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function renderSubagentCall(
|
|
222
|
+
label: string,
|
|
223
|
+
args: { task: string; cwd?: string },
|
|
224
|
+
theme: any,
|
|
225
|
+
) {
|
|
226
|
+
const firstLine = args.task.split("\n")[0];
|
|
227
|
+
const combined = args.cwd ? `${shortenPath(args.cwd)} | ${firstLine}` : firstLine;
|
|
228
|
+
const preview = combined.length > 80 ? combined.slice(0, 77) + "..." : combined;
|
|
229
|
+
return new Text(
|
|
230
|
+
`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", preview)}`,
|
|
231
|
+
0, 0,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderSubagentResult(
|
|
236
|
+
result: { content: Array<{ type: string; text?: string }>; isError?: boolean; details?: any },
|
|
237
|
+
options: { expanded: boolean },
|
|
238
|
+
theme: any,
|
|
239
|
+
context: { toolCallId: string },
|
|
240
|
+
) {
|
|
241
|
+
if (!subagentSpinners.has(context.toolCallId)) {
|
|
242
|
+
subagentSpinners.set(context.toolCallId, createSpinner());
|
|
243
|
+
}
|
|
244
|
+
const spin = subagentSpinners.get(context.toolCallId)!;
|
|
245
|
+
const raw = result.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("");
|
|
246
|
+
const output = raw.split("\n").map(l => l.startsWith("▸") ? theme.fg("muted", l) : l).join("\n");
|
|
247
|
+
const container = new Container();
|
|
248
|
+
|
|
249
|
+
if (result.details?.final) subagentSpinners.delete(context.toolCallId);
|
|
250
|
+
|
|
251
|
+
if (!options.expanded) {
|
|
252
|
+
const text = output || theme.fg("dim", "(no output)");
|
|
253
|
+
const usage = result.details?.usage;
|
|
254
|
+
const hint = ` · ${theme.fg("dim", "Ctrl+O to expand")}`;
|
|
255
|
+
const status = usage ? formatUsage(usage, spin) + hint : "";
|
|
256
|
+
container.addChild(new CompactPreview(text));
|
|
257
|
+
if (status) container.addChild(new Text(theme.fg("dim", status), 0, 0));
|
|
258
|
+
return container;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const isFinal = !!result.details?.final;
|
|
262
|
+
if (isFinal) {
|
|
263
|
+
const mdTheme = getMarkdownTheme();
|
|
264
|
+
container.addChild(new Markdown(raw, 0, 0, mdTheme));
|
|
265
|
+
} else {
|
|
266
|
+
container.addChild(new CompactPreview(output, 15));
|
|
267
|
+
}
|
|
268
|
+
if (result.details?.usage) {
|
|
269
|
+
container.addChild(new Spacer(1));
|
|
270
|
+
container.addChild(new Text(theme.fg("dim", formatUsage(result.details.usage, spin)), 0, 0));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return container;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Extension entry point
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
export default function (pi: ExtensionAPI) {
|
|
281
|
+
const role = getCurrentRole();
|
|
282
|
+
|
|
283
|
+
// Review/explore children: register git tool only, then return
|
|
284
|
+
if (role === "review" || role === "explore") {
|
|
285
|
+
pi.registerTool({
|
|
286
|
+
name: "git",
|
|
287
|
+
label: "Git",
|
|
288
|
+
description: "Run read-only git commands (log, diff, status, show, branch, blame).",
|
|
289
|
+
promptSnippet: "Run a read-only git command",
|
|
290
|
+
parameters: Type.Object({
|
|
291
|
+
command: Type.String({
|
|
292
|
+
description: "Git command with arguments (e.g. 'log --oneline -10', 'diff HEAD~1', 'status').",
|
|
293
|
+
}),
|
|
294
|
+
}),
|
|
295
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
296
|
+
try {
|
|
297
|
+
const args = params.command.trim().split(/\s+/);
|
|
298
|
+
if (args.length === 0) {
|
|
299
|
+
return { content: [{ type: "text" as const, text: "No git command provided." }], isError: true };
|
|
300
|
+
}
|
|
301
|
+
const subcommand = args[0];
|
|
302
|
+
const READONLY_GIT = new Set(["log","diff","show","status","branch","blame",
|
|
303
|
+
"rev-parse","rev-list","ls-tree","ls-files","grep","describe","shortlog","whatchanged"]);
|
|
304
|
+
if (!READONLY_GIT.has(subcommand)) {
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text" as const, text: `git ${subcommand} is not allowed (read-only commands only).` }],
|
|
307
|
+
isError: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const output = execFileSync("git", args, {
|
|
311
|
+
cwd: ctx.cwd,
|
|
312
|
+
encoding: "utf-8",
|
|
313
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
314
|
+
timeout: 30_000,
|
|
315
|
+
});
|
|
316
|
+
return { content: [{ type: "text" as const, text: output }] };
|
|
317
|
+
} catch (err: any) {
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text" as const, text: `git ${params.command}\n\n${err.stderr || err.message}` }],
|
|
320
|
+
isError: true,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
// Delegate tool: always registered (cache compatibility), rejects in delegate children
|
|
330
|
+
pi.registerTool({
|
|
331
|
+
name: "delegate",
|
|
332
|
+
label: "Delegate",
|
|
333
|
+
description:
|
|
334
|
+
"Delegate a task to an isolated subagent process. " +
|
|
335
|
+
"For general-purpose work that doesn't fit the review or explore tools.",
|
|
336
|
+
promptSnippet: "Delegate a task to a worker subagent",
|
|
337
|
+
promptGuidelines: [
|
|
338
|
+
"Use the delegate tool for non-trivial, self-contained implementation tasks — it has full tool access unlike the read-only review and explore tools.",
|
|
339
|
+
],
|
|
340
|
+
parameters: DelegateParams,
|
|
341
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
342
|
+
if (role === "delegate") {
|
|
343
|
+
return {
|
|
344
|
+
content: [
|
|
345
|
+
{
|
|
346
|
+
type: "text" as const,
|
|
347
|
+
text: "Delegation not available in delegate subagents. Use review or explore instead.",
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
isError: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return subagentExecute(
|
|
354
|
+
params.cwd ?? ctx.cwd,
|
|
355
|
+
params.task,
|
|
356
|
+
params.skills,
|
|
357
|
+
undefined, // no tool filtering — cache-compatible
|
|
358
|
+
EXTENSION_FILE, // same extensions → same system prompt → cache hit
|
|
359
|
+
undefined, // no system prompt — cache-compatible
|
|
360
|
+
"delegate", // delegate children can spawn review/explore but not delegate further
|
|
361
|
+
signal,
|
|
362
|
+
onUpdate,
|
|
363
|
+
);
|
|
364
|
+
},
|
|
365
|
+
renderCall: (args, theme) => renderSubagentCall("delegate ", args, theme),
|
|
366
|
+
renderResult: (result, options, theme, context) => renderSubagentResult(result as any, options, theme, context),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Review tool
|
|
370
|
+
pi.registerTool({
|
|
371
|
+
name: "review",
|
|
372
|
+
label: "Review",
|
|
373
|
+
description:
|
|
374
|
+
"Review code changes or files in the current project. " +
|
|
375
|
+
"The reviewer is always read-only and runs in the parent's working directory. " +
|
|
376
|
+
"Use for code review, diff inspection, issue analysis, and quality checks.",
|
|
377
|
+
promptSnippet: "Review code or files with a read-only subagent",
|
|
378
|
+
promptGuidelines: [
|
|
379
|
+
"Use the review tool for code review, diff inspection, and quality checks — it is read-only and cannot modify files.",
|
|
380
|
+
],
|
|
381
|
+
parameters: ReviewParams,
|
|
382
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
383
|
+
return subagentExecute(
|
|
384
|
+
ctx.cwd,
|
|
385
|
+
params.task,
|
|
386
|
+
params.skills,
|
|
387
|
+
READONLY_TOOLS,
|
|
388
|
+
EXTENSION_FILE,
|
|
389
|
+
REVIEW_PROMPT_FILE,
|
|
390
|
+
"review",
|
|
391
|
+
signal,
|
|
392
|
+
onUpdate,
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
renderCall: (args, theme) => renderSubagentCall("review ", args, theme),
|
|
396
|
+
renderResult: (result, options, theme, context) => renderSubagentResult(result as any, options, theme, context),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Explore tool
|
|
400
|
+
pi.registerTool({
|
|
401
|
+
name: "explore",
|
|
402
|
+
label: "Explore",
|
|
403
|
+
description:
|
|
404
|
+
"Explore a project directory to understand its structure, patterns, and key files. " +
|
|
405
|
+
"The explorer is always read-only and runs in the specified working directory. " +
|
|
406
|
+
"Use for mapping codebases, scouting dependencies, or understanding unfamiliar projects.",
|
|
407
|
+
promptSnippet: "Explore a project directory with a read-only subagent",
|
|
408
|
+
promptGuidelines: [
|
|
409
|
+
"Use the explore tool to map unfamiliar codebases or scout dependencies — it runs read-only in the specified directory.",
|
|
410
|
+
],
|
|
411
|
+
parameters: ExploreParams,
|
|
412
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
413
|
+
return subagentExecute(
|
|
414
|
+
params.cwd,
|
|
415
|
+
params.task,
|
|
416
|
+
params.skills,
|
|
417
|
+
READONLY_TOOLS,
|
|
418
|
+
EXTENSION_FILE,
|
|
419
|
+
EXPLORE_PROMPT_FILE,
|
|
420
|
+
"explore",
|
|
421
|
+
signal,
|
|
422
|
+
onUpdate,
|
|
423
|
+
);
|
|
424
|
+
},
|
|
425
|
+
renderCall: (args, theme) => renderSubagentCall("explore ", args, theme),
|
|
426
|
+
renderResult: (result, options, theme, context) => renderSubagentResult(result as any, options, theme, context),
|
|
427
|
+
});
|
|
428
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jerryan/pi-subagent-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal pi extension: delegate, review, and explore via isolated subagent processes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"spawn.ts",
|
|
10
|
+
"tui.ts",
|
|
11
|
+
"prompts/*.md",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"pi-package",
|
|
19
|
+
"subagent",
|
|
20
|
+
"delegate",
|
|
21
|
+
"review",
|
|
22
|
+
"explore"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Zerui An",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/JerryAZR/pi-subagent-tools"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"pretest": "node -e \"const{cp}=require('fs');cp('test/mocks/@earendil-works','node_modules/@earendil-works',{recursive:true},()=>{})\"",
|
|
35
|
+
"test": "node --experimental-strip-types --test test/*.test.ts"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": ["./index.ts"]
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
48
|
+
"@earendil-works/pi-tui": "^0.74.0",
|
|
49
|
+
"@sinclair/typebox": "^0.34.0",
|
|
50
|
+
"@types/node": "^20.0.0",
|
|
51
|
+
"typescript": "^5.4.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
You are a codebase explorer. Map the structure, identify key files, and report patterns.
|
|
2
|
+
You are in read-only mode — use the git tool for repository inspection. Do not modify files or run destructive commands.
|
|
3
|
+
Focus on entry points, architecture, dependencies, and areas of interest.
|
|
4
|
+
End with a clear summary of the codebase structure and notable findings.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
You are a code reviewer. Inspect code, find issues, and report findings clearly.
|
|
2
|
+
You are in read-only mode — use the git tool for repository inspection. Do not modify files or run destructive commands.
|
|
3
|
+
Focus on correctness, edge cases, readability, and adherence to the task requirements.
|
|
4
|
+
End with a clear summary of findings organized by severity.
|
package/spawn.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared subagent spawn logic.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
10
|
+
import type { UsageStats } from "./tui.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface SpawnOptions {
|
|
17
|
+
cwd: string;
|
|
18
|
+
task: string;
|
|
19
|
+
skills?: string[];
|
|
20
|
+
allowedTools?: string[];
|
|
21
|
+
extensionFile?: string;
|
|
22
|
+
systemPromptFile?: string;
|
|
23
|
+
childRole?: "review" | "explore" | "delegate";
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
onUpdate?: (result: AgentToolResult) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SpawnResult {
|
|
29
|
+
output: string;
|
|
30
|
+
isError: boolean;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
tokens?: { input: number; output: number; total: number };
|
|
33
|
+
turns: number;
|
|
34
|
+
toolCalls: string[];
|
|
35
|
+
durationMs: number;
|
|
36
|
+
stderr?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Constants
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export const READONLY_TOOLS = ["read", "grep", "find", "ls", "git"];
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Pi process detection
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export function getPiInvocation(args: string[]): {
|
|
50
|
+
command: string;
|
|
51
|
+
args: string[];
|
|
52
|
+
} {
|
|
53
|
+
const currentScript = process.argv[1];
|
|
54
|
+
if (currentScript && fs.existsSync(currentScript)) {
|
|
55
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
56
|
+
}
|
|
57
|
+
const npmGlobalRoot = process.env.APPDATA
|
|
58
|
+
? path.join(process.env.APPDATA, "npm", "node_modules")
|
|
59
|
+
: process.env.HOME
|
|
60
|
+
? path.join(process.env.HOME, ".npm-global", "node_modules")
|
|
61
|
+
: null;
|
|
62
|
+
if (npmGlobalRoot) {
|
|
63
|
+
const cliPath = path.join(npmGlobalRoot, "@earendil-works", "pi-coding-agent", "dist", "cli.js");
|
|
64
|
+
if (fs.existsSync(cliPath)) {
|
|
65
|
+
return { command: process.execPath, args: [cliPath, ...args] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
69
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
70
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
71
|
+
return { command: "pi", args };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Argument construction
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export function buildSpawnArgs(opts: SpawnOptions): string[] {
|
|
79
|
+
const args: string[] = ["--mode", "json", "-p"];
|
|
80
|
+
|
|
81
|
+
args.push("--no-session");
|
|
82
|
+
|
|
83
|
+
if (opts.allowedTools && opts.allowedTools.length > 0) {
|
|
84
|
+
args.push("--tools", opts.allowedTools.join(","));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const skill of opts.skills ?? []) {
|
|
88
|
+
args.push("--skill", skill);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (opts.extensionFile) {
|
|
92
|
+
args.push("--extension", opts.extensionFile);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (opts.systemPromptFile) {
|
|
96
|
+
args.push("--append-system-prompt", opts.systemPromptFile);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
args.push(opts.task);
|
|
100
|
+
return args;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// JSONL parsing
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function getFinalOutput(messages: Message[]): string {
|
|
108
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
109
|
+
const msg = messages[i];
|
|
110
|
+
if (msg.role === "assistant") {
|
|
111
|
+
for (const part of msg.content) {
|
|
112
|
+
if (part.type === "text") return part.text ?? "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Main spawn function
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export async function runSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
124
|
+
const args = buildSpawnArgs(opts);
|
|
125
|
+
const invocation = getPiInvocation(args);
|
|
126
|
+
|
|
127
|
+
let buffer = "";
|
|
128
|
+
let stderr = "";
|
|
129
|
+
let spawnError: string | null = null;
|
|
130
|
+
const messages: Message[] = [];
|
|
131
|
+
let turnCount = 0;
|
|
132
|
+
let tokens = { input: 0, output: 0, total: 0 };
|
|
133
|
+
const toolCallNames: string[] = [];
|
|
134
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
135
|
+
if (opts.childRole) env.PI_SUBAGENT_TOOLS_ROLE = opts.childRole;
|
|
136
|
+
|
|
137
|
+
const startTime = Date.now();
|
|
138
|
+
|
|
139
|
+
let previousTurnsText = "";
|
|
140
|
+
let streamText = "";
|
|
141
|
+
|
|
142
|
+
const displayText = () => previousTurnsText + (previousTurnsText && streamText ? "\n" : "") + streamText;
|
|
143
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
144
|
+
cwd: opts.cwd,
|
|
145
|
+
shell: false,
|
|
146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
147
|
+
env,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const MAX_MSG_COUNT = 5;
|
|
151
|
+
const MAX_PREV_LINES = 20;
|
|
152
|
+
|
|
153
|
+
const trimPrev = () => {
|
|
154
|
+
const lines = previousTurnsText.split("\n");
|
|
155
|
+
if (lines.length > MAX_PREV_LINES) previousTurnsText = lines.slice(-MAX_PREV_LINES).join("\n");
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let lastUpdate = 0;
|
|
159
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
160
|
+
const UPDATE_INTERVAL = 200;
|
|
161
|
+
|
|
162
|
+
const doUpdate = () => {
|
|
163
|
+
if (!opts.onUpdate) return;
|
|
164
|
+
const bottom = displayText();
|
|
165
|
+
opts.onUpdate({
|
|
166
|
+
content: [{ type: "text", text: bottom }],
|
|
167
|
+
details: { usage: { turns: turnCount, input: tokens.input, output: tokens.output, total: tokens.total, durationMs: Date.now() - startTime } satisfies UsageStats, toolCalls: [...toolCallNames] },
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const fireUpdate = () => {
|
|
172
|
+
if (!opts.onUpdate) return;
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
if (now - lastUpdate >= UPDATE_INTERVAL) {
|
|
175
|
+
lastUpdate = now;
|
|
176
|
+
doUpdate();
|
|
177
|
+
} else if (!pendingTimer) {
|
|
178
|
+
pendingTimer = setTimeout(() => {
|
|
179
|
+
pendingTimer = null;
|
|
180
|
+
lastUpdate = Date.now();
|
|
181
|
+
doUpdate();
|
|
182
|
+
}, UPDATE_INTERVAL - (now - lastUpdate));
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const processLine = (line: string) => {
|
|
187
|
+
if (!line.trim()) return;
|
|
188
|
+
let event: any;
|
|
189
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
190
|
+
|
|
191
|
+
// Per-token streaming: show text on every message_update
|
|
192
|
+
if (event.type === "message_update" && event.message) {
|
|
193
|
+
const text = (event.message.content ?? [])
|
|
194
|
+
.filter((c: any) => c.type === "text" || c.type === "thinking")
|
|
195
|
+
.map((c: any) => c[c.type]).join("");
|
|
196
|
+
streamText = text;
|
|
197
|
+
fireUpdate();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (event.type === "message_end" && event.message) {
|
|
201
|
+
const msg = event.message as Message & { content: any[] };
|
|
202
|
+
messages.push(msg);
|
|
203
|
+
if (messages.length > MAX_MSG_COUNT) messages.splice(0, messages.length - MAX_MSG_COUNT);
|
|
204
|
+
if (msg.role === "assistant") {
|
|
205
|
+
turnCount++;
|
|
206
|
+
if (msg.usage) {
|
|
207
|
+
tokens.input += msg.usage.input || 0;
|
|
208
|
+
tokens.output += msg.usage.output || 0;
|
|
209
|
+
tokens.total += msg.usage.totalTokens || 0;
|
|
210
|
+
}
|
|
211
|
+
if (streamText) {
|
|
212
|
+
previousTurnsText = previousTurnsText ? previousTurnsText + "\n" + streamText : streamText;
|
|
213
|
+
streamText = "";
|
|
214
|
+
trimPrev();
|
|
215
|
+
} else {
|
|
216
|
+
const text = (msg.content ?? []).filter((c: any) => c.type === "text" || c.type === "thinking").map((c: any) => c[c.type]).join("");
|
|
217
|
+
if (text) {
|
|
218
|
+
previousTurnsText = previousTurnsText ? previousTurnsText + "\n" + text : text;
|
|
219
|
+
trimPrev();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const tc of (msg.content ?? []).filter((c: any) => c.type === "toolCall")) {
|
|
223
|
+
toolCallNames.push(tc.name);
|
|
224
|
+
const parts = [`▸ ${tc.name}`];
|
|
225
|
+
if (tc.arguments && typeof tc.arguments === "object" && !Array.isArray(tc.arguments)) {
|
|
226
|
+
const entries = Object.entries(tc.arguments);
|
|
227
|
+
const capPerParam = entries.length > 1;
|
|
228
|
+
for (const [k, v] of entries) {
|
|
229
|
+
const val = typeof v === "string" ? v : JSON.stringify(v);
|
|
230
|
+
const short = capPerParam && val.length > 40 ? val.slice(0, 37) + "..." : val;
|
|
231
|
+
parts.push(`${k}=${short}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let line = parts.join(" ");
|
|
235
|
+
if (line.length > 80) line = line.slice(0, 77) + "...";
|
|
236
|
+
previousTurnsText = previousTurnsText ? `${previousTurnsText}\n${line}` : line;
|
|
237
|
+
trimPrev();
|
|
238
|
+
}
|
|
239
|
+
fireUpdate();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
245
|
+
const killProc = () => { proc.kill("SIGTERM"); setTimeout(() => { if (!proc.killed) try { proc.kill("SIGKILL"); } catch { /* not supported on all platforms */ } }, 5000); };
|
|
246
|
+
if (opts.signal) {
|
|
247
|
+
if (opts.signal.aborted) killProc();
|
|
248
|
+
else opts.signal.addEventListener("abort", killProc, { once: true });
|
|
249
|
+
}
|
|
250
|
+
proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) processLine(line); });
|
|
251
|
+
proc.stderr.on("data", (data) => { stderr += data.toString(); });
|
|
252
|
+
proc.on("close", (code) => { if (opts.signal) opts.signal.removeEventListener("abort", killProc); if (buffer.trim()) processLine(buffer); resolve(code ?? 1); });
|
|
253
|
+
proc.on("error", (err) => { if (opts.signal) opts.signal.removeEventListener("abort", killProc); spawnError = err.message; resolve(1); });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (opts.signal?.aborted) return { output: "Subagent was aborted", isError: true, exitCode: 1, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
|
|
257
|
+
if (spawnError) return { output: `Failed to start subagent: ${spawnError}`, isError: true, exitCode: 1, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
|
|
258
|
+
if (exitCode !== 0) return { output: stderr || `Subagent exited with code ${exitCode}`, isError: true, exitCode, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
|
|
259
|
+
|
|
260
|
+
const output = getFinalOutput(messages);
|
|
261
|
+
return { output: output || "(no output)", isError: false, exitCode: 0, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime, stderr: stderr.trim() || undefined };
|
|
262
|
+
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering helpers for pi-subagent-tools.
|
|
3
|
+
*
|
|
4
|
+
* Tool call cards, result display, progress indicators.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shorten a path by replacing the home directory with ~
|
|
11
|
+
*/
|
|
12
|
+
export function shortenPath(p: string): string {
|
|
13
|
+
const normalized = p.toLowerCase();
|
|
14
|
+
for (const home of [process.env.HOME, process.env.USERPROFILE, os.homedir()]) {
|
|
15
|
+
if (home && normalized.startsWith(home.toLowerCase())) {
|
|
16
|
+
return `~${p.slice(home.length)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format duration in human-readable form
|
|
24
|
+
*/
|
|
25
|
+
export function formatDuration(ms: number): string {
|
|
26
|
+
if (ms < 1000) return `${ms}ms`;
|
|
27
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
28
|
+
return `${Math.floor(ms / 60000)}m${String(Math.floor((ms % 60000) / 1000)).padStart(2, "0")}s`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format token count with k suffix for large numbers
|
|
33
|
+
*/
|
|
34
|
+
export function formatTokens(n: number): string {
|
|
35
|
+
let s: string;
|
|
36
|
+
if (n < 1000) s = String(n) + " "; // trailing spaces give same visual weight as k/M suffix; padStart right-aligns
|
|
37
|
+
else if (n < 1_000_000) s = (Math.trunc(n / 100) / 10).toFixed(1) + "k";
|
|
38
|
+
else s = (Math.trunc(n / 100_000) / 10).toFixed(1) + "M";
|
|
39
|
+
return s.padStart(6);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
|
+
|
|
44
|
+
export function createSpinner(): () => string {
|
|
45
|
+
let idx = 0;
|
|
46
|
+
return () => {
|
|
47
|
+
const frame = SPINNER[idx];
|
|
48
|
+
idx = (idx + 1) % SPINNER.length;
|
|
49
|
+
return frame;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UsageStats {
|
|
54
|
+
turns: number;
|
|
55
|
+
input: number;
|
|
56
|
+
output: number;
|
|
57
|
+
total: number;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatUsage(u: UsageStats, spin: () => string): string {
|
|
62
|
+
const parts: string[] = [`${spin()} · Turn ${u.turns}`];
|
|
63
|
+
parts.push(`Tokens: input ${formatTokens(u.input)} | output ${formatTokens(u.output)}`);
|
|
64
|
+
if (u.durationMs != null) parts.push(formatDuration(u.durationMs));
|
|
65
|
+
return parts.join(" · ");
|
|
66
|
+
}
|