@d3ara1n/pi-subagent 0.3.0 → 0.5.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 +63 -22
- package/package.json +1 -1
- package/src/config.ts +10 -3
- package/src/index.ts +469 -284
- package/src/spawn.ts +158 -53
- package/src/types.ts +42 -2
- package/src/utils.test.ts +278 -0
- package/src/utils.ts +286 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pi-subagent pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency: runs on node's built-in test runner.
|
|
5
|
+
* node --test packages/pi-subagent/src/utils.test.ts
|
|
6
|
+
*
|
|
7
|
+
* These guard the bug fixes introduced during the improvement rounds:
|
|
8
|
+
* path-injection (sanitizeFilename), concurrency/abort/negative-active
|
|
9
|
+
* (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
|
|
10
|
+
* formatting (previewArgs), output truncation fallback (truncateOutput).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import {
|
|
16
|
+
sanitizeFilename,
|
|
17
|
+
isProviderError,
|
|
18
|
+
AsyncSemaphore,
|
|
19
|
+
previewArgs,
|
|
20
|
+
truncateOutput,
|
|
21
|
+
formatTokens,
|
|
22
|
+
effectiveTimeout,
|
|
23
|
+
elapsedSeconds,
|
|
24
|
+
} from "./utils.ts";
|
|
25
|
+
import type { SubagentResult, SubagentRole } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
// ── sanitizeFilename: guards the path-injection fix ──
|
|
28
|
+
describe("sanitizeFilename", () => {
|
|
29
|
+
test("never yields a path separator (no directory traversal)", () => {
|
|
30
|
+
// Core security contract: result contains no / or \, so it can't escape the dir via path.join.
|
|
31
|
+
for (const input of ["../../etc", "../passwd", "/etc/passwd", "a/b/c", "a\\b", "..", "///"]) {
|
|
32
|
+
const out = sanitizeFilename(input);
|
|
33
|
+
assert.ok(!out.includes("/"), `${input} -> "${out}" still contains /`);
|
|
34
|
+
assert.ok(!out.includes("\\"), `${input} -> "${out}" still contains \\`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test("empty string falls back to unknown", () => {
|
|
38
|
+
assert.equal(sanitizeFilename(""), "unknown");
|
|
39
|
+
});
|
|
40
|
+
test("pure-dots collapses to unknown (leading dots stripped, rest empty)", () => {
|
|
41
|
+
assert.equal(sanitizeFilename(".."), "unknown");
|
|
42
|
+
assert.equal(sanitizeFilename("..."), "unknown");
|
|
43
|
+
});
|
|
44
|
+
test("special chars become underscores", () => {
|
|
45
|
+
assert.equal(sanitizeFilename("!!!"), "___");
|
|
46
|
+
assert.equal(sanitizeFilename(" "), "___");
|
|
47
|
+
assert.equal(sanitizeFilename("///"), "___");
|
|
48
|
+
assert.equal(sanitizeFilename("a/b/c"), "a_b_c");
|
|
49
|
+
});
|
|
50
|
+
test("keeps normal uuid/alnum/dots/dashes as-is", () => {
|
|
51
|
+
const id = "019eff4f-b603-7623-9eaa-17d32eb623d9";
|
|
52
|
+
assert.equal(sanitizeFilename(id), id);
|
|
53
|
+
assert.equal(sanitizeFilename("call_abc123.json"), "call_abc123.json");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── isProviderError: guards the #9 expanded word list ──
|
|
58
|
+
describe("isProviderError", () => {
|
|
59
|
+
const mk = (stderr: string, errorMessage = ""): SubagentResult =>
|
|
60
|
+
({
|
|
61
|
+
stderr,
|
|
62
|
+
errorMessage,
|
|
63
|
+
role: "",
|
|
64
|
+
task: "",
|
|
65
|
+
exitCode: 0,
|
|
66
|
+
messages: [],
|
|
67
|
+
output: "",
|
|
68
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
69
|
+
activityLog: [],
|
|
70
|
+
}) as unknown as SubagentResult;
|
|
71
|
+
|
|
72
|
+
test("matches provider error keywords", () => {
|
|
73
|
+
const cases = [
|
|
74
|
+
"429 Too Many Requests",
|
|
75
|
+
"quota exceeded",
|
|
76
|
+
"rate limit exceeded",
|
|
77
|
+
"authentication error",
|
|
78
|
+
"request timeout",
|
|
79
|
+
"quota exhausted",
|
|
80
|
+
"service unavailable",
|
|
81
|
+
"503 Service Unavailable",
|
|
82
|
+
"internal server error",
|
|
83
|
+
"temporary failure",
|
|
84
|
+
"request declined",
|
|
85
|
+
"server overloaded",
|
|
86
|
+
"ECONNRESET",
|
|
87
|
+
"socket hang up",
|
|
88
|
+
"EPIPE",
|
|
89
|
+
"network error",
|
|
90
|
+
"connection refused",
|
|
91
|
+
];
|
|
92
|
+
for (const c of cases) {
|
|
93
|
+
assert.equal(isProviderError(mk(c)), true, `should match: ${c}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test("does not match business/programming errors", () => {
|
|
97
|
+
assert.equal(isProviderError(mk("TypeError: Cannot read properties of undefined")), false);
|
|
98
|
+
assert.equal(isProviderError(mk("Error: test failed, expected 5 got 3")), false);
|
|
99
|
+
assert.equal(isProviderError(mk("AssertionError: values differ")), false);
|
|
100
|
+
assert.equal(isProviderError(mk("")), false);
|
|
101
|
+
});
|
|
102
|
+
test("checks errorMessage too, not just stderr", () => {
|
|
103
|
+
assert.equal(isProviderError(mk("", "rate limited")), true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── AsyncSemaphore: guards concurrency cap, negative-active, abort cleanup ──
|
|
108
|
+
describe("AsyncSemaphore", () => {
|
|
109
|
+
test("never goes negative on extra release", async () => {
|
|
110
|
+
const s = new AsyncSemaphore(1);
|
|
111
|
+
await s.acquire();
|
|
112
|
+
s.release();
|
|
113
|
+
s.release();
|
|
114
|
+
s.release();
|
|
115
|
+
assert.equal((s as any).active, 0);
|
|
116
|
+
});
|
|
117
|
+
test("respects concurrency cap (queues beyond max)", async () => {
|
|
118
|
+
const s = new AsyncSemaphore(2);
|
|
119
|
+
await s.acquire();
|
|
120
|
+
await s.acquire();
|
|
121
|
+
let entered = false;
|
|
122
|
+
const p = s.acquire().then(() => {
|
|
123
|
+
entered = true;
|
|
124
|
+
});
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
assert.equal(entered, false); // still queued
|
|
128
|
+
s.release();
|
|
129
|
+
await p;
|
|
130
|
+
assert.equal(entered, true);
|
|
131
|
+
});
|
|
132
|
+
test("abort removes waiter from queue and rejects", async () => {
|
|
133
|
+
const s = new AsyncSemaphore(1);
|
|
134
|
+
await s.acquire();
|
|
135
|
+
const c = new AbortController();
|
|
136
|
+
const p = s.acquire(c.signal);
|
|
137
|
+
c.abort();
|
|
138
|
+
await assert.rejects(p);
|
|
139
|
+
assert.equal((s as any).waiters.length, 0);
|
|
140
|
+
});
|
|
141
|
+
test("releases queued waiters in FIFO order", async () => {
|
|
142
|
+
const s = new AsyncSemaphore(1);
|
|
143
|
+
await s.acquire();
|
|
144
|
+
const order: number[] = [];
|
|
145
|
+
const p1 = s.acquire().then(() => order.push(1));
|
|
146
|
+
const p2 = s.acquire().then(() => order.push(2));
|
|
147
|
+
const p3 = s.acquire().then(() => order.push(3));
|
|
148
|
+
s.release();
|
|
149
|
+
await p1;
|
|
150
|
+
s.release();
|
|
151
|
+
await p2;
|
|
152
|
+
s.release();
|
|
153
|
+
await p3;
|
|
154
|
+
assert.deepEqual(order, [1, 2, 3]);
|
|
155
|
+
});
|
|
156
|
+
test("acquires immediately when under cap", async () => {
|
|
157
|
+
const s = new AsyncSemaphore(3);
|
|
158
|
+
await s.acquire();
|
|
159
|
+
await s.acquire();
|
|
160
|
+
assert.equal((s as any).active, 2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── previewArgs: guards the #10 shape-based formatting ──
|
|
165
|
+
describe("previewArgs", () => {
|
|
166
|
+
test("command -> $ prefix", () => {
|
|
167
|
+
assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
|
|
168
|
+
});
|
|
169
|
+
test("command truncated at 60 chars", () => {
|
|
170
|
+
const long = "x".repeat(70);
|
|
171
|
+
const r = previewArgs({ command: long });
|
|
172
|
+
assert.ok(r.startsWith("$ "));
|
|
173
|
+
assert.ok(r.endsWith("..."));
|
|
174
|
+
assert.ok(r.length < long.length);
|
|
175
|
+
});
|
|
176
|
+
test("file_path is shortened (home -> ~)", () => {
|
|
177
|
+
const r = previewArgs({ file_path: "/home/user/foo.ts" });
|
|
178
|
+
assert.ok(r.includes("foo.ts"));
|
|
179
|
+
});
|
|
180
|
+
test("url passthrough (truncated when long)", () => {
|
|
181
|
+
assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
|
|
182
|
+
const longUrl = "https://" + "x".repeat(70);
|
|
183
|
+
assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
|
|
184
|
+
});
|
|
185
|
+
test("query/pattern/regex/search -> /.../ form", () => {
|
|
186
|
+
assert.equal(previewArgs({ query: "foo" }), "/foo/");
|
|
187
|
+
assert.equal(previewArgs({ pattern: "bar" }), "/bar/");
|
|
188
|
+
assert.equal(previewArgs({ regex: "baz" }), "/baz/");
|
|
189
|
+
assert.equal(previewArgs({ search: "qux" }), "/qux/");
|
|
190
|
+
});
|
|
191
|
+
test("empty object falls back to JSON {}", () => {
|
|
192
|
+
assert.equal(previewArgs({}), "{}");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── effectiveTimeout: guards delegate-role auto-widening (seconds) ──
|
|
197
|
+
describe("effectiveTimeout", () => {
|
|
198
|
+
const role = (tools: string[], timeout?: number): SubagentRole =>
|
|
199
|
+
({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "", timeout }) as unknown as SubagentRole;
|
|
200
|
+
|
|
201
|
+
test("non-delegate role uses base timeout", () => {
|
|
202
|
+
assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
|
|
203
|
+
});
|
|
204
|
+
test("delegate role doubles base when no explicit timeout", () => {
|
|
205
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
|
|
206
|
+
});
|
|
207
|
+
test("explicit roleDef.timeout is always honored (no widening)", () => {
|
|
208
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
|
|
209
|
+
});
|
|
210
|
+
test("explicit timeout on non-delegate also honored", () => {
|
|
211
|
+
assert.equal(effectiveTimeout(role(["read"]), 600), 600);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ── truncateOutput: guards the #2 head+tail fallback ──
|
|
216
|
+
describe("truncateOutput", () => {
|
|
217
|
+
test("adds truncation header with original length", () => {
|
|
218
|
+
const big = "x".repeat(60000);
|
|
219
|
+
const r = truncateOutput(big);
|
|
220
|
+
assert.ok(r.startsWith("[Output truncated"));
|
|
221
|
+
assert.ok(r.includes("60000 chars total"));
|
|
222
|
+
assert.ok(r.includes("[truncated]"));
|
|
223
|
+
});
|
|
224
|
+
test("keeps head and tail, drops the middle", () => {
|
|
225
|
+
// 120000 chars: 40k H + 40k M + 40k T
|
|
226
|
+
const content = "H".repeat(40000) + "M".repeat(40000) + "T".repeat(40000);
|
|
227
|
+
const r = truncateOutput(content);
|
|
228
|
+
assert.ok(r.includes("H"), "head preserved");
|
|
229
|
+
assert.ok(r.includes("T"), "tail preserved");
|
|
230
|
+
assert.ok(!r.includes("M"), "middle dropped");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── formatTokens: boundary correctness ──
|
|
235
|
+
describe("formatTokens", () => {
|
|
236
|
+
test("under 1000 stays raw", () => {
|
|
237
|
+
assert.equal(formatTokens(0), "0");
|
|
238
|
+
assert.equal(formatTokens(999), "999");
|
|
239
|
+
});
|
|
240
|
+
test("1000-9999 with one decimal place", () => {
|
|
241
|
+
assert.equal(formatTokens(1000), "1.0k");
|
|
242
|
+
assert.equal(formatTokens(9500), "9.5k");
|
|
243
|
+
// 9999/1000 = 9.999, toFixed(1) rounds up to 10.0
|
|
244
|
+
assert.equal(formatTokens(9999), "10.0k");
|
|
245
|
+
});
|
|
246
|
+
test("10000-999999 rounded to integer k", () => {
|
|
247
|
+
assert.equal(formatTokens(10000), "10k");
|
|
248
|
+
assert.equal(formatTokens(999999), "1000k");
|
|
249
|
+
});
|
|
250
|
+
test(">= 1000000 in M", () => {
|
|
251
|
+
assert.equal(formatTokens(1000000), "1.0M");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── elapsedSeconds: live/terminal time derivation ──
|
|
256
|
+
describe("elapsedSeconds", () => {
|
|
257
|
+
test("terminal state: rounds elapsedMs to whole seconds", () => {
|
|
258
|
+
assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 12345 }), 12);
|
|
259
|
+
assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 400 }), 0);
|
|
260
|
+
assert.equal(elapsedSeconds({ exitCode: 1, elapsedMs: 59999 }), 60);
|
|
261
|
+
});
|
|
262
|
+
test("terminal state without elapsedMs -> undefined", () => {
|
|
263
|
+
assert.equal(elapsedSeconds({ exitCode: 0 }), undefined);
|
|
264
|
+
});
|
|
265
|
+
test("queued (running sentinel, no startTime) -> undefined", () => {
|
|
266
|
+
assert.equal(elapsedSeconds({ exitCode: -1 }), undefined);
|
|
267
|
+
});
|
|
268
|
+
test("running: live seconds from startTime (within ~1s drift)", () => {
|
|
269
|
+
const start = Date.now() - 3500;
|
|
270
|
+
const s = elapsedSeconds({ exitCode: -1, startTime: start });
|
|
271
|
+
assert.ok(s !== undefined, "should be defined while running");
|
|
272
|
+
assert.ok(s >= 3 && s <= 4, `expected ~3s, got ${s}`);
|
|
273
|
+
});
|
|
274
|
+
test("running: clamps negative drift (future startTime) to 0", () => {
|
|
275
|
+
const start = Date.now() + 10000; // 10s in the future
|
|
276
|
+
assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
/**
|
|
35
|
+
* 计算用于展示的运行耗时(秒)。
|
|
36
|
+
* - 运行中(exitCode === -1 且有 startTime):基于墙钟实时计算;
|
|
37
|
+
* - 终态(exitCode !== -1 且有 elapsedMs):冻结值;
|
|
38
|
+
* - queued 或字段缺失:undefined(调用方应跳过耗时展示)。
|
|
39
|
+
*
|
|
40
|
+
* 用结构子集而非 SubagentResult,便于在无需引入完整类型的纯工具上下文中复用与测试。
|
|
41
|
+
*/
|
|
42
|
+
export function elapsedSeconds(r: {
|
|
43
|
+
exitCode: number;
|
|
44
|
+
startTime?: number;
|
|
45
|
+
elapsedMs?: number;
|
|
46
|
+
}): number | undefined {
|
|
47
|
+
if (r.exitCode === -1 && typeof r.startTime === "number") {
|
|
48
|
+
return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
|
|
49
|
+
}
|
|
50
|
+
if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
|
|
51
|
+
return Math.round(r.elapsedMs / 1000);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type DisplayItem =
|
|
57
|
+
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
58
|
+
| { type: "thinking"; status?: ToolStatus };
|
|
59
|
+
|
|
60
|
+
/** Map the real-time activity log into renderable display items (in order). */
|
|
61
|
+
export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
62
|
+
return activityLog.map((a) =>
|
|
63
|
+
a.kind === "thinking"
|
|
64
|
+
? { type: "thinking", status: a.status }
|
|
65
|
+
: { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function shortenPath(p: string): string {
|
|
70
|
+
const home = os.homedir();
|
|
71
|
+
if (process.platform === "win32") {
|
|
72
|
+
return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
|
|
73
|
+
}
|
|
74
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatToolCall(
|
|
78
|
+
toolName: string,
|
|
79
|
+
args: Record<string, unknown>,
|
|
80
|
+
fg: (color: string, text: string) => string,
|
|
81
|
+
): string {
|
|
82
|
+
switch (toolName) {
|
|
83
|
+
case "delegate": {
|
|
84
|
+
const subRole = args.role as string | undefined;
|
|
85
|
+
return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
|
|
86
|
+
}
|
|
87
|
+
case "bash": {
|
|
88
|
+
const command = (args.command as string) || "...";
|
|
89
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
90
|
+
return fg("muted", "$ ") + fg("toolOutput", preview);
|
|
91
|
+
}
|
|
92
|
+
case "read": {
|
|
93
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
94
|
+
const filePath = shortenPath(rawPath);
|
|
95
|
+
const offset = args.offset as number | undefined;
|
|
96
|
+
const limit = args.limit as number | undefined;
|
|
97
|
+
let text = fg("accent", filePath);
|
|
98
|
+
if (offset !== undefined || limit !== undefined) {
|
|
99
|
+
const startLine = offset ?? 1;
|
|
100
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
101
|
+
text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
102
|
+
}
|
|
103
|
+
return fg("muted", "read ") + text;
|
|
104
|
+
}
|
|
105
|
+
case "write": {
|
|
106
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
107
|
+
const content = (args.content || "") as string;
|
|
108
|
+
const lines = content.split("\n").length;
|
|
109
|
+
let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
|
|
110
|
+
if (lines > 1) text += fg("dim", ` (${lines} lines)`);
|
|
111
|
+
return text;
|
|
112
|
+
}
|
|
113
|
+
case "edit": {
|
|
114
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
115
|
+
return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
|
|
116
|
+
}
|
|
117
|
+
case "grep": {
|
|
118
|
+
const pattern = (args.pattern || "") as string;
|
|
119
|
+
const rawPath = (args.path || ".") as string;
|
|
120
|
+
return fg("muted", "grep ") + fg("accent", `/${pattern}/`) + fg("dim", ` in ${shortenPath(rawPath)}`);
|
|
121
|
+
}
|
|
122
|
+
case "find": {
|
|
123
|
+
const pattern = (args.pattern || "*") as string;
|
|
124
|
+
return fg("muted", "find ") + fg("accent", pattern);
|
|
125
|
+
}
|
|
126
|
+
case "glob": {
|
|
127
|
+
const pattern = (args.pattern || "*") as string;
|
|
128
|
+
return fg("muted", "glob ") + fg("accent", pattern);
|
|
129
|
+
}
|
|
130
|
+
default: {
|
|
131
|
+
const preview = previewArgs(args);
|
|
132
|
+
return fg("accent", toolName) + (preview ? fg("dim", ` ${preview}`) : "");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
|
|
138
|
+
export function statusStyle(
|
|
139
|
+
status: ToolStatus | undefined,
|
|
140
|
+
fg: (color: string, text: string) => string,
|
|
141
|
+
): { prefix: string; color: (c: string, text: string) => string } {
|
|
142
|
+
switch (status) {
|
|
143
|
+
case "running":
|
|
144
|
+
return { prefix: fg("accent", "\u2192 "), color: fg };
|
|
145
|
+
case "failed":
|
|
146
|
+
return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
|
|
147
|
+
case "done":
|
|
148
|
+
default:
|
|
149
|
+
return { prefix: fg("dim", "\u2022 "), color: (_c, text) => fg("dim", text) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Render a thinking-block row: diamond glyph + label, colored by status.
|
|
154
|
+
* Running = hollow diamond (unformed thought); done = solid diamond (settled). */
|
|
155
|
+
export function formatThinking(
|
|
156
|
+
status: ToolStatus | undefined,
|
|
157
|
+
fg: (color: string, text: string) => string,
|
|
158
|
+
): string {
|
|
159
|
+
if (status === "running") {
|
|
160
|
+
return fg("accent", "\u25C7 thinking");
|
|
161
|
+
}
|
|
162
|
+
// done (or unknown) — dim past tense, solid diamond
|
|
163
|
+
return fg("dim", "\u25C6 thought");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderDisplayItems(
|
|
167
|
+
items: DisplayItem[],
|
|
168
|
+
limit: number | undefined,
|
|
169
|
+
fg: (color: string, text: string) => string,
|
|
170
|
+
): string {
|
|
171
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
172
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
173
|
+
let text = "";
|
|
174
|
+
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
175
|
+
for (const item of toShow) {
|
|
176
|
+
if (item.type === "thinking") {
|
|
177
|
+
text += `${formatThinking(item.status, fg)}\n`;
|
|
178
|
+
} else {
|
|
179
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
180
|
+
text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return text.trimEnd();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isFailedResult(r: SubagentResult): boolean {
|
|
187
|
+
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || r.stopReason === "timeout";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
|
|
191
|
+
export function isProviderError(result: SubagentResult): boolean {
|
|
192
|
+
const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
|
|
193
|
+
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);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Shape-based preview for tools we don't have a dedicated formatter for. */
|
|
197
|
+
export function previewArgs(args: Record<string, unknown>): string {
|
|
198
|
+
const command = args.command as string | undefined;
|
|
199
|
+
if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
|
|
200
|
+
const fp = (args.file_path || args.path) as string | undefined;
|
|
201
|
+
if (fp) return shortenPath(fp);
|
|
202
|
+
const url = args.url as string | undefined;
|
|
203
|
+
if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
|
|
204
|
+
const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
|
|
205
|
+
if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
|
|
206
|
+
const argsStr = JSON.stringify(args);
|
|
207
|
+
return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Concurrency gate ───────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Promise-based semaphore capping concurrent subagent spawns.
|
|
214
|
+
* acquire() resolves immediately while under the limit, otherwise queues.
|
|
215
|
+
* Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
|
|
216
|
+
*/
|
|
217
|
+
export class AsyncSemaphore {
|
|
218
|
+
private active = 0;
|
|
219
|
+
private waiters: Array<() => void> = [];
|
|
220
|
+
private max: number;
|
|
221
|
+
constructor(max: number) {
|
|
222
|
+
this.max = max;
|
|
223
|
+
}
|
|
224
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
225
|
+
if (this.active < this.max) {
|
|
226
|
+
this.active++;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
return new Promise<void>((resolve, reject) => {
|
|
230
|
+
const wakeup = () => {
|
|
231
|
+
signal?.removeEventListener("abort", onAbort);
|
|
232
|
+
this.active++;
|
|
233
|
+
resolve();
|
|
234
|
+
};
|
|
235
|
+
const onAbort = () => {
|
|
236
|
+
signal?.removeEventListener("abort", onAbort);
|
|
237
|
+
const idx = this.waiters.indexOf(wakeup);
|
|
238
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
239
|
+
reject(new Error("aborted while waiting for concurrency slot"));
|
|
240
|
+
};
|
|
241
|
+
this.waiters.push(wakeup);
|
|
242
|
+
if (signal) {
|
|
243
|
+
if (signal.aborted) {
|
|
244
|
+
onAbort();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
release(): void {
|
|
252
|
+
this.active = Math.max(0, this.active - 1);
|
|
253
|
+
const next = this.waiters.shift();
|
|
254
|
+
if (next) next();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Timeout policy ────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Effective per-role timeout. Roles that can `delegate` need headroom for
|
|
262
|
+
* nested runs to complete, so when no explicit per-role timeout is set we
|
|
263
|
+
* double the base. An explicit roleDef.timeout (seconds) is always honored as-is.
|
|
264
|
+
* All inputs/outputs are in SECONDS — convert to ms at the spawn boundary.
|
|
265
|
+
*/
|
|
266
|
+
export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
|
|
267
|
+
const canDelegate = (roleDef.tools ?? []).includes("delegate");
|
|
268
|
+
if (canDelegate && roleDef.timeout == null) {
|
|
269
|
+
return baseTimeoutSec * 2;
|
|
270
|
+
}
|
|
271
|
+
return roleDef.timeout ?? baseTimeoutSec;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Output truncation ────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** Strip path separators / traversal so sessionId/toolCallId can't escape the history dir. */
|
|
277
|
+
export function sanitizeFilename(s: string): string {
|
|
278
|
+
return s.replace(/[^\w.-]/g, "_").replace(/^[.]+/, "") || "unknown";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Mechanical fallback: keep head (findings) + tail (summary), drop the middle. */
|
|
282
|
+
export function truncateOutput(t: string): string {
|
|
283
|
+
const head = t.slice(0, 30_000);
|
|
284
|
+
const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
285
|
+
return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
286
|
+
}
|