@d3ara1n/pi-subagent 0.6.0 → 0.7.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/package.json +3 -2
- package/preview.png +0 -0
- package/src/config.ts +38 -47
- package/src/index.ts +990 -810
- package/src/roles.ts +11 -16
- package/src/spawn.ts +438 -402
- package/src/utils.test.ts +243 -227
- package/src/utils.ts +196 -185
package/src/utils.ts
CHANGED
|
@@ -13,22 +13,22 @@ import type { ActivityEntry, SubagentRole, SubagentResult, ToolStatus } from "./
|
|
|
13
13
|
export const MAX_OUTPUT_CHARS = 50_000;
|
|
14
14
|
|
|
15
15
|
export function formatTokens(count: number): string {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
if (count < 1000) return count.toString();
|
|
17
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
18
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
19
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function formatUsageStats(usage: SubagentResult["usage"], model?: string): string {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
25
|
+
if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
|
|
26
|
+
if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
|
|
27
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
28
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
29
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
30
|
+
if (model) parts.push(model);
|
|
31
|
+
return parts.join(" ");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -41,157 +41,168 @@ export function formatUsageStats(usage: SubagentResult["usage"], model?: string)
|
|
|
41
41
|
* and tested in pure-helper contexts without importing the full type.
|
|
42
42
|
*/
|
|
43
43
|
export function elapsedSeconds(r: {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
exitCode: number;
|
|
45
|
+
startTime?: number;
|
|
46
|
+
elapsedMs?: number;
|
|
47
47
|
}): number | undefined {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
if (r.exitCode === -1 && typeof r.startTime === "number") {
|
|
49
|
+
return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
|
|
50
|
+
}
|
|
51
|
+
if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
|
|
52
|
+
return Math.round(r.elapsedMs / 1000);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export type DisplayItem =
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
59
|
+
| { type: "thinking"; status?: ToolStatus };
|
|
60
60
|
|
|
61
61
|
/** Map the real-time activity log into renderable display items (in order). */
|
|
62
62
|
export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
return activityLog.map((a) =>
|
|
64
|
+
a.kind === "thinking"
|
|
65
|
+
? { type: "thinking", status: a.status }
|
|
66
|
+
: { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
|
|
67
|
+
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function shortenPath(p: string): string {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const home = os.homedir();
|
|
72
|
+
if (process.platform === "win32") {
|
|
73
|
+
return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
|
|
74
|
+
}
|
|
75
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function formatToolCall(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
toolName: string,
|
|
80
|
+
args: Record<string, unknown>,
|
|
81
|
+
fg: (color: string, text: string) => string,
|
|
82
82
|
): string {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
83
|
+
switch (toolName) {
|
|
84
|
+
case "delegate": {
|
|
85
|
+
const subRole = args.role as string | undefined;
|
|
86
|
+
return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
|
|
87
|
+
}
|
|
88
|
+
case "bash": {
|
|
89
|
+
const command = (args.command as string) || "...";
|
|
90
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
91
|
+
return fg("muted", "$ ") + fg("toolOutput", preview);
|
|
92
|
+
}
|
|
93
|
+
case "read": {
|
|
94
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
95
|
+
const filePath = shortenPath(rawPath);
|
|
96
|
+
const offset = args.offset as number | undefined;
|
|
97
|
+
const limit = args.limit as number | undefined;
|
|
98
|
+
let text = fg("accent", filePath);
|
|
99
|
+
if (offset !== undefined || limit !== undefined) {
|
|
100
|
+
const startLine = offset ?? 1;
|
|
101
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
102
|
+
text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
103
|
+
}
|
|
104
|
+
return fg("muted", "read ") + text;
|
|
105
|
+
}
|
|
106
|
+
case "write": {
|
|
107
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
108
|
+
const content = (args.content || "") as string;
|
|
109
|
+
const lines = content.split("\n").length;
|
|
110
|
+
let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
|
|
111
|
+
if (lines > 1) text += fg("dim", ` (${lines} lines)`);
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
case "edit": {
|
|
115
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
116
|
+
return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
|
|
117
|
+
}
|
|
118
|
+
case "grep": {
|
|
119
|
+
const pattern = (args.pattern || "") as string;
|
|
120
|
+
const rawPath = (args.path || ".") as string;
|
|
121
|
+
return (
|
|
122
|
+
fg("muted", "grep ") +
|
|
123
|
+
fg("accent", `/${pattern}/`) +
|
|
124
|
+
fg("dim", ` in ${shortenPath(rawPath)}`)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
case "find": {
|
|
128
|
+
const pattern = (args.pattern || "*") as string;
|
|
129
|
+
return fg("muted", "find ") + fg("accent", pattern);
|
|
130
|
+
}
|
|
131
|
+
case "glob": {
|
|
132
|
+
const pattern = (args.pattern || "*") as string;
|
|
133
|
+
return fg("muted", "glob ") + fg("accent", pattern);
|
|
134
|
+
}
|
|
135
|
+
default: {
|
|
136
|
+
const preview = previewArgs(args);
|
|
137
|
+
return fg("accent", toolName) + (preview ? fg("dim", ` ${preview}`) : "");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
/** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
|
|
139
143
|
export function statusStyle(
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
status: ToolStatus | undefined,
|
|
145
|
+
fg: (color: string, text: string) => string,
|
|
142
146
|
): { prefix: string; color: (c: string, text: string) => string } {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
switch (status) {
|
|
148
|
+
case "running":
|
|
149
|
+
return { prefix: fg("accent", "\u2192 "), color: fg };
|
|
150
|
+
case "failed":
|
|
151
|
+
return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
|
|
152
|
+
case "done":
|
|
153
|
+
default:
|
|
154
|
+
return { prefix: fg("dim", "\u2022 "), color: (_c, text) => fg("dim", text) };
|
|
155
|
+
}
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
/** Render a thinking-block row: diamond glyph + label, colored by status.
|
|
155
159
|
* Running = hollow diamond (unformed thought); done = solid diamond (settled). */
|
|
156
160
|
export function formatThinking(
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
status: ToolStatus | undefined,
|
|
162
|
+
fg: (color: string, text: string) => string,
|
|
159
163
|
): string {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
if (status === "running") {
|
|
165
|
+
return fg("accent", "\u25C7 thinking");
|
|
166
|
+
}
|
|
167
|
+
// done (or unknown) — dim past tense, solid diamond
|
|
168
|
+
return fg("dim", "\u25C6 thought");
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
export function renderDisplayItems(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
172
|
+
items: DisplayItem[],
|
|
173
|
+
limit: number | undefined,
|
|
174
|
+
fg: (color: string, text: string) => string,
|
|
171
175
|
): string {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
176
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
177
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
178
|
+
let text = "";
|
|
179
|
+
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
180
|
+
for (const item of toShow) {
|
|
181
|
+
if (item.type === "thinking") {
|
|
182
|
+
text += `${formatThinking(item.status, fg)}\n`;
|
|
183
|
+
} else {
|
|
184
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
185
|
+
text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return text.trimEnd();
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
export function isFailedResult(r: SubagentResult): boolean {
|
|
188
|
-
|
|
192
|
+
return (
|
|
193
|
+
r.exitCode !== 0 ||
|
|
194
|
+
r.stopReason === "error" ||
|
|
195
|
+
r.stopReason === "aborted" ||
|
|
196
|
+
r.stopReason === "timeout"
|
|
197
|
+
);
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
/** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
|
|
192
201
|
export function isProviderError(result: SubagentResult): boolean {
|
|
193
|
-
|
|
194
|
-
|
|
202
|
+
const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
|
|
203
|
+
return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(
|
|
204
|
+
haystack,
|
|
205
|
+
);
|
|
195
206
|
}
|
|
196
207
|
|
|
197
208
|
/**
|
|
@@ -199,16 +210,16 @@ export function isProviderError(result: SubagentResult): boolean {
|
|
|
199
210
|
* @internal — exported for testing; used internally by {@link formatToolCall}.
|
|
200
211
|
*/
|
|
201
212
|
export function previewArgs(args: Record<string, unknown>): string {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
const command = args.command as string | undefined;
|
|
214
|
+
if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
|
|
215
|
+
const fp = (args.file_path || args.path) as string | undefined;
|
|
216
|
+
if (fp) return shortenPath(fp);
|
|
217
|
+
const url = args.url as string | undefined;
|
|
218
|
+
if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
|
|
219
|
+
const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
|
|
220
|
+
if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
|
|
221
|
+
const argsStr = JSON.stringify(args);
|
|
222
|
+
return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
|
|
212
223
|
}
|
|
213
224
|
|
|
214
225
|
// ── Concurrency gate ───────────────────────────────────────────────
|
|
@@ -219,44 +230,44 @@ export function previewArgs(args: Record<string, unknown>): string {
|
|
|
219
230
|
* Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
|
|
220
231
|
*/
|
|
221
232
|
export class AsyncSemaphore {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
233
|
+
private active = 0;
|
|
234
|
+
private waiters: Array<() => void> = [];
|
|
235
|
+
private max: number;
|
|
236
|
+
constructor(max: number) {
|
|
237
|
+
this.max = max;
|
|
238
|
+
}
|
|
239
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
240
|
+
if (this.active < this.max) {
|
|
241
|
+
this.active++;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
return new Promise<void>((resolve, reject) => {
|
|
245
|
+
const wakeup = () => {
|
|
246
|
+
signal?.removeEventListener("abort", onAbort);
|
|
247
|
+
this.active++;
|
|
248
|
+
resolve();
|
|
249
|
+
};
|
|
250
|
+
const onAbort = () => {
|
|
251
|
+
signal?.removeEventListener("abort", onAbort);
|
|
252
|
+
const idx = this.waiters.indexOf(wakeup);
|
|
253
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
254
|
+
reject(new Error("aborted while waiting for concurrency slot"));
|
|
255
|
+
};
|
|
256
|
+
this.waiters.push(wakeup);
|
|
257
|
+
if (signal) {
|
|
258
|
+
if (signal.aborted) {
|
|
259
|
+
onAbort();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
release(): void {
|
|
267
|
+
this.active = Math.max(0, this.active - 1);
|
|
268
|
+
const next = this.waiters.shift();
|
|
269
|
+
if (next) next();
|
|
270
|
+
}
|
|
260
271
|
}
|
|
261
272
|
|
|
262
273
|
// ── Timeout policy ────────────────────────────────────────
|
|
@@ -268,23 +279,23 @@ export class AsyncSemaphore {
|
|
|
268
279
|
* All inputs/outputs are in SECONDS — convert to ms at the spawn boundary.
|
|
269
280
|
*/
|
|
270
281
|
export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
const canDelegate = (roleDef.tools ?? []).includes("delegate");
|
|
283
|
+
if (canDelegate && roleDef.timeout == null) {
|
|
284
|
+
return baseTimeoutSec * 2;
|
|
285
|
+
}
|
|
286
|
+
return roleDef.timeout ?? baseTimeoutSec;
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
// ── Output truncation ────────────────────────────────────────
|
|
279
290
|
|
|
280
291
|
/** Strip path separators / traversal so sessionId/toolCallId can't escape the history dir. */
|
|
281
292
|
export function sanitizeFilename(s: string): string {
|
|
282
|
-
|
|
293
|
+
return s.replace(/[^\w.-]/g, "_").replace(/^[.]+/, "") || "unknown";
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
/** Mechanical fallback: keep head (findings) + tail (summary), drop the middle. */
|
|
286
297
|
export function truncateOutput(t: string): string {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
298
|
+
const head = t.slice(0, 30_000);
|
|
299
|
+
const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
300
|
+
return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
290
301
|
}
|