@d3ara1n/pi-subagent 0.2.0 → 0.4.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/README.md +58 -12
- package/package.json +2 -1
- package/src/config.ts +9 -2
- package/src/index.ts +411 -237
- package/src/spawn.ts +125 -40
- package/src/types.ts +46 -1
- package/src/utils.test.ts +252 -0
- package/src/utils.ts +263 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for pi-subagent: formatting, sanitization, formatting helpers,
|
|
3
|
+
* and the concurrency semaphore. No pi-API or I/O dependencies — safe to unit-test.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from index.ts so these can be exercised directly. index.ts imports
|
|
6
|
+
* them; behavior is unchanged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import type { ActivityEntry, SubagentRole, SubagentResult, ToolStatus } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** Max output chars fed to the main model and the expanded TUI. Larger outputs are compressed (or truncated) to fit. */
|
|
13
|
+
export const MAX_OUTPUT_CHARS = 50_000;
|
|
14
|
+
|
|
15
|
+
export function formatTokens(count: number): string {
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatUsageStats(usage: SubagentResult["usage"], model?: string): string {
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DisplayItem =
|
|
35
|
+
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
36
|
+
| { type: "thinking"; status?: ToolStatus };
|
|
37
|
+
|
|
38
|
+
/** Map the real-time activity log into renderable display items (in order). */
|
|
39
|
+
export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
40
|
+
return activityLog.map((a) =>
|
|
41
|
+
a.kind === "thinking"
|
|
42
|
+
? { type: "thinking", status: a.status }
|
|
43
|
+
: { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function shortenPath(p: string): string {
|
|
48
|
+
const home = os.homedir();
|
|
49
|
+
if (process.platform === "win32") {
|
|
50
|
+
return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
|
|
51
|
+
}
|
|
52
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatToolCall(
|
|
56
|
+
toolName: string,
|
|
57
|
+
args: Record<string, unknown>,
|
|
58
|
+
fg: (color: string, text: string) => string,
|
|
59
|
+
): string {
|
|
60
|
+
switch (toolName) {
|
|
61
|
+
case "delegate": {
|
|
62
|
+
const subRole = args.role as string | undefined;
|
|
63
|
+
return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
|
|
64
|
+
}
|
|
65
|
+
case "bash": {
|
|
66
|
+
const command = (args.command as string) || "...";
|
|
67
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
68
|
+
return fg("muted", "$ ") + fg("toolOutput", preview);
|
|
69
|
+
}
|
|
70
|
+
case "read": {
|
|
71
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
72
|
+
const filePath = shortenPath(rawPath);
|
|
73
|
+
const offset = args.offset as number | undefined;
|
|
74
|
+
const limit = args.limit as number | undefined;
|
|
75
|
+
let text = fg("accent", filePath);
|
|
76
|
+
if (offset !== undefined || limit !== undefined) {
|
|
77
|
+
const startLine = offset ?? 1;
|
|
78
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
79
|
+
text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
return fg("muted", "read ") + text;
|
|
82
|
+
}
|
|
83
|
+
case "write": {
|
|
84
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
85
|
+
const content = (args.content || "") as string;
|
|
86
|
+
const lines = content.split("\n").length;
|
|
87
|
+
let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
|
|
88
|
+
if (lines > 1) text += fg("dim", ` (${lines} lines)`);
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
case "edit": {
|
|
92
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
93
|
+
return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
|
|
94
|
+
}
|
|
95
|
+
case "grep": {
|
|
96
|
+
const pattern = (args.pattern || "") as string;
|
|
97
|
+
const rawPath = (args.path || ".") as string;
|
|
98
|
+
return fg("muted", "grep ") + fg("accent", `/${pattern}/`) + fg("dim", ` in ${shortenPath(rawPath)}`);
|
|
99
|
+
}
|
|
100
|
+
case "find": {
|
|
101
|
+
const pattern = (args.pattern || "*") as string;
|
|
102
|
+
return fg("muted", "find ") + fg("accent", pattern);
|
|
103
|
+
}
|
|
104
|
+
case "glob": {
|
|
105
|
+
const pattern = (args.pattern || "*") as string;
|
|
106
|
+
return fg("muted", "glob ") + fg("accent", pattern);
|
|
107
|
+
}
|
|
108
|
+
default: {
|
|
109
|
+
const preview = previewArgs(args);
|
|
110
|
+
return fg("accent", toolName) + (preview ? fg("dim", ` ${preview}`) : "");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
|
|
116
|
+
export function statusStyle(
|
|
117
|
+
status: ToolStatus | undefined,
|
|
118
|
+
fg: (color: string, text: string) => string,
|
|
119
|
+
): { prefix: string; color: (c: string, text: string) => string } {
|
|
120
|
+
switch (status) {
|
|
121
|
+
case "running":
|
|
122
|
+
return { prefix: fg("accent", "\u2192 "), color: fg };
|
|
123
|
+
case "failed":
|
|
124
|
+
return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
|
|
125
|
+
case "done":
|
|
126
|
+
default:
|
|
127
|
+
return { prefix: fg("dim", "\u2022 "), color: (_c, text) => fg("dim", text) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Render a thinking-block row: diamond glyph + label, colored by status.
|
|
132
|
+
* Running = hollow diamond (unformed thought); done = solid diamond (settled). */
|
|
133
|
+
export function formatThinking(
|
|
134
|
+
status: ToolStatus | undefined,
|
|
135
|
+
fg: (color: string, text: string) => string,
|
|
136
|
+
): string {
|
|
137
|
+
if (status === "running") {
|
|
138
|
+
return fg("accent", "\u25C7 thinking");
|
|
139
|
+
}
|
|
140
|
+
// done (or unknown) — dim past tense, solid diamond
|
|
141
|
+
return fg("dim", "\u25C6 thought");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderDisplayItems(
|
|
145
|
+
items: DisplayItem[],
|
|
146
|
+
limit: number | undefined,
|
|
147
|
+
fg: (color: string, text: string) => string,
|
|
148
|
+
): string {
|
|
149
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
150
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
151
|
+
let text = "";
|
|
152
|
+
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
153
|
+
for (const item of toShow) {
|
|
154
|
+
if (item.type === "thinking") {
|
|
155
|
+
text += `${formatThinking(item.status, fg)}\n`;
|
|
156
|
+
} else {
|
|
157
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
158
|
+
text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return text.trimEnd();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isFailedResult(r: SubagentResult): boolean {
|
|
165
|
+
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || r.stopReason === "timeout";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
|
|
169
|
+
export function isProviderError(result: SubagentResult): boolean {
|
|
170
|
+
const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
|
|
171
|
+
return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(haystack);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Shape-based preview for tools we don't have a dedicated formatter for. */
|
|
175
|
+
export function previewArgs(args: Record<string, unknown>): string {
|
|
176
|
+
const command = args.command as string | undefined;
|
|
177
|
+
if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
|
|
178
|
+
const fp = (args.file_path || args.path) as string | undefined;
|
|
179
|
+
if (fp) return shortenPath(fp);
|
|
180
|
+
const url = args.url as string | undefined;
|
|
181
|
+
if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
|
|
182
|
+
const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
|
|
183
|
+
if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
|
|
184
|
+
const argsStr = JSON.stringify(args);
|
|
185
|
+
return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Concurrency gate ───────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Promise-based semaphore capping concurrent subagent spawns.
|
|
192
|
+
* acquire() resolves immediately while under the limit, otherwise queues.
|
|
193
|
+
* Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
|
|
194
|
+
*/
|
|
195
|
+
export class AsyncSemaphore {
|
|
196
|
+
private active = 0;
|
|
197
|
+
private waiters: Array<() => void> = [];
|
|
198
|
+
private max: number;
|
|
199
|
+
constructor(max: number) {
|
|
200
|
+
this.max = max;
|
|
201
|
+
}
|
|
202
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
203
|
+
if (this.active < this.max) {
|
|
204
|
+
this.active++;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
return new Promise<void>((resolve, reject) => {
|
|
208
|
+
const wakeup = () => {
|
|
209
|
+
signal?.removeEventListener("abort", onAbort);
|
|
210
|
+
this.active++;
|
|
211
|
+
resolve();
|
|
212
|
+
};
|
|
213
|
+
const onAbort = () => {
|
|
214
|
+
signal?.removeEventListener("abort", onAbort);
|
|
215
|
+
const idx = this.waiters.indexOf(wakeup);
|
|
216
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
217
|
+
reject(new Error("aborted while waiting for concurrency slot"));
|
|
218
|
+
};
|
|
219
|
+
this.waiters.push(wakeup);
|
|
220
|
+
if (signal) {
|
|
221
|
+
if (signal.aborted) {
|
|
222
|
+
onAbort();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
release(): void {
|
|
230
|
+
this.active = Math.max(0, this.active - 1);
|
|
231
|
+
const next = this.waiters.shift();
|
|
232
|
+
if (next) next();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Timeout policy ────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Effective per-role timeout. Roles that can `delegate` need headroom for
|
|
240
|
+
* nested runs to complete, so when no explicit per-role timeout is set we
|
|
241
|
+
* double the base. An explicit roleDef.timeoutMs is always honored as-is.
|
|
242
|
+
*/
|
|
243
|
+
export function effectiveTimeoutMs(roleDef: SubagentRole, baseTimeoutMs: number): number {
|
|
244
|
+
const canDelegate = (roleDef.tools ?? []).includes("delegate");
|
|
245
|
+
if (canDelegate && roleDef.timeoutMs == null) {
|
|
246
|
+
return baseTimeoutMs * 2;
|
|
247
|
+
}
|
|
248
|
+
return roleDef.timeoutMs ?? baseTimeoutMs;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Output truncation ────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/** Strip path separators / traversal so sessionId/toolCallId can't escape the history dir. */
|
|
254
|
+
export function sanitizeFilename(s: string): string {
|
|
255
|
+
return s.replace(/[^\w.-]/g, "_").replace(/^[.]+/, "") || "unknown";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Mechanical fallback: keep head (findings) + tail (summary), drop the middle. */
|
|
259
|
+
export function truncateOutput(t: string): string {
|
|
260
|
+
const head = t.slice(0, 30_000);
|
|
261
|
+
const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
262
|
+
return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
263
|
+
}
|