@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.test.ts
CHANGED
|
@@ -13,266 +13,282 @@
|
|
|
13
13
|
import { test, describe } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
sanitizeFilename,
|
|
17
|
+
isProviderError,
|
|
18
|
+
AsyncSemaphore,
|
|
19
|
+
previewArgs,
|
|
20
|
+
truncateOutput,
|
|
21
|
+
formatTokens,
|
|
22
|
+
effectiveTimeout,
|
|
23
|
+
elapsedSeconds,
|
|
24
24
|
} from "./utils.ts";
|
|
25
25
|
import type { SubagentResult, SubagentRole } from "./types.ts";
|
|
26
26
|
|
|
27
27
|
// ── sanitizeFilename: guards the path-injection fix ──
|
|
28
28
|
describe("sanitizeFilename", () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
55
|
});
|
|
56
56
|
|
|
57
57
|
// ── isProviderError: guards the #9 expanded word list ──
|
|
58
58
|
describe("isProviderError", () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: {
|
|
69
|
+
input: 0,
|
|
70
|
+
output: 0,
|
|
71
|
+
cacheRead: 0,
|
|
72
|
+
cacheWrite: 0,
|
|
73
|
+
cost: 0,
|
|
74
|
+
contextTokens: 0,
|
|
75
|
+
turns: 0,
|
|
76
|
+
},
|
|
77
|
+
activityLog: [],
|
|
78
|
+
}) as unknown as SubagentResult;
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
80
|
+
test("matches provider error keywords", () => {
|
|
81
|
+
const cases = [
|
|
82
|
+
"429 Too Many Requests",
|
|
83
|
+
"quota exceeded",
|
|
84
|
+
"rate limit exceeded",
|
|
85
|
+
"authentication error",
|
|
86
|
+
"request timeout",
|
|
87
|
+
"quota exhausted",
|
|
88
|
+
"service unavailable",
|
|
89
|
+
"503 Service Unavailable",
|
|
90
|
+
"internal server error",
|
|
91
|
+
"temporary failure",
|
|
92
|
+
"request declined",
|
|
93
|
+
"server overloaded",
|
|
94
|
+
"ECONNRESET",
|
|
95
|
+
"socket hang up",
|
|
96
|
+
"EPIPE",
|
|
97
|
+
"network error",
|
|
98
|
+
"connection refused",
|
|
99
|
+
];
|
|
100
|
+
for (const c of cases) {
|
|
101
|
+
assert.equal(isProviderError(mk(c)), true, `should match: ${c}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
test("does not match business/programming errors", () => {
|
|
105
|
+
assert.equal(isProviderError(mk("TypeError: Cannot read properties of undefined")), false);
|
|
106
|
+
assert.equal(isProviderError(mk("Error: test failed, expected 5 got 3")), false);
|
|
107
|
+
assert.equal(isProviderError(mk("AssertionError: values differ")), false);
|
|
108
|
+
assert.equal(isProviderError(mk("")), false);
|
|
109
|
+
});
|
|
110
|
+
test("checks errorMessage too, not just stderr", () => {
|
|
111
|
+
assert.equal(isProviderError(mk("", "rate limited")), true);
|
|
112
|
+
});
|
|
105
113
|
});
|
|
106
114
|
|
|
107
115
|
// ── AsyncSemaphore: guards concurrency cap, negative-active, abort cleanup ──
|
|
108
116
|
describe("AsyncSemaphore", () => {
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
117
|
+
test("never goes negative on extra release", async () => {
|
|
118
|
+
const s = new AsyncSemaphore(1);
|
|
119
|
+
await s.acquire();
|
|
120
|
+
s.release();
|
|
121
|
+
s.release();
|
|
122
|
+
s.release();
|
|
123
|
+
assert.equal((s as any).active, 0);
|
|
124
|
+
});
|
|
125
|
+
test("respects concurrency cap (queues beyond max)", async () => {
|
|
126
|
+
const s = new AsyncSemaphore(2);
|
|
127
|
+
await s.acquire();
|
|
128
|
+
await s.acquire();
|
|
129
|
+
let entered = false;
|
|
130
|
+
const p = s.acquire().then(() => {
|
|
131
|
+
entered = true;
|
|
132
|
+
});
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
assert.equal(entered, false); // still queued
|
|
136
|
+
s.release();
|
|
137
|
+
await p;
|
|
138
|
+
assert.equal(entered, true);
|
|
139
|
+
});
|
|
140
|
+
test("abort removes waiter from queue and rejects", async () => {
|
|
141
|
+
const s = new AsyncSemaphore(1);
|
|
142
|
+
await s.acquire();
|
|
143
|
+
const c = new AbortController();
|
|
144
|
+
const p = s.acquire(c.signal);
|
|
145
|
+
c.abort();
|
|
146
|
+
await assert.rejects(p);
|
|
147
|
+
assert.equal((s as any).waiters.length, 0);
|
|
148
|
+
});
|
|
149
|
+
test("releases queued waiters in FIFO order", async () => {
|
|
150
|
+
const s = new AsyncSemaphore(1);
|
|
151
|
+
await s.acquire();
|
|
152
|
+
const order: number[] = [];
|
|
153
|
+
const p1 = s.acquire().then(() => order.push(1));
|
|
154
|
+
const p2 = s.acquire().then(() => order.push(2));
|
|
155
|
+
const p3 = s.acquire().then(() => order.push(3));
|
|
156
|
+
s.release();
|
|
157
|
+
await p1;
|
|
158
|
+
s.release();
|
|
159
|
+
await p2;
|
|
160
|
+
s.release();
|
|
161
|
+
await p3;
|
|
162
|
+
assert.deepEqual(order, [1, 2, 3]);
|
|
163
|
+
});
|
|
164
|
+
test("acquires immediately when under cap", async () => {
|
|
165
|
+
const s = new AsyncSemaphore(3);
|
|
166
|
+
await s.acquire();
|
|
167
|
+
await s.acquire();
|
|
168
|
+
assert.equal((s as any).active, 2);
|
|
169
|
+
});
|
|
162
170
|
});
|
|
163
171
|
|
|
164
172
|
// ── previewArgs: guards the #10 shape-based formatting ──
|
|
165
173
|
describe("previewArgs", () => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
174
|
+
test("command -> $ prefix", () => {
|
|
175
|
+
assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
|
|
176
|
+
});
|
|
177
|
+
test("command truncated at 60 chars", () => {
|
|
178
|
+
const long = "x".repeat(70);
|
|
179
|
+
const r = previewArgs({ command: long });
|
|
180
|
+
assert.ok(r.startsWith("$ "));
|
|
181
|
+
assert.ok(r.endsWith("..."));
|
|
182
|
+
assert.ok(r.length < long.length);
|
|
183
|
+
});
|
|
184
|
+
test("file_path is shortened (home -> ~)", () => {
|
|
185
|
+
const r = previewArgs({ file_path: "/home/user/foo.ts" });
|
|
186
|
+
assert.ok(r.includes("foo.ts"));
|
|
187
|
+
});
|
|
188
|
+
test("url passthrough (truncated when long)", () => {
|
|
189
|
+
assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
|
|
190
|
+
const longUrl = "https://" + "x".repeat(70);
|
|
191
|
+
assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
|
|
192
|
+
});
|
|
193
|
+
test("query/pattern/regex/search -> /.../ form", () => {
|
|
194
|
+
assert.equal(previewArgs({ query: "foo" }), "/foo/");
|
|
195
|
+
assert.equal(previewArgs({ pattern: "bar" }), "/bar/");
|
|
196
|
+
assert.equal(previewArgs({ regex: "baz" }), "/baz/");
|
|
197
|
+
assert.equal(previewArgs({ search: "qux" }), "/qux/");
|
|
198
|
+
});
|
|
199
|
+
test("empty object falls back to JSON {}", () => {
|
|
200
|
+
assert.equal(previewArgs({}), "{}");
|
|
201
|
+
});
|
|
194
202
|
});
|
|
195
203
|
|
|
196
204
|
// ── effectiveTimeout: guards delegate-role auto-widening (seconds) ──
|
|
197
205
|
describe("effectiveTimeout", () => {
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
const role = (tools: string[], timeout?: number): SubagentRole =>
|
|
207
|
+
({
|
|
208
|
+
role: "default",
|
|
209
|
+
description: "",
|
|
210
|
+
examples: [],
|
|
211
|
+
decisionTrigger: "",
|
|
212
|
+
tools,
|
|
213
|
+
systemPrompt: "",
|
|
214
|
+
timeout,
|
|
215
|
+
}) as unknown as SubagentRole;
|
|
200
216
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
test("non-delegate role uses base timeout", () => {
|
|
218
|
+
assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
|
|
219
|
+
});
|
|
220
|
+
test("delegate role doubles base when no explicit timeout", () => {
|
|
221
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
|
|
222
|
+
});
|
|
223
|
+
test("explicit roleDef.timeout is always honored (no widening)", () => {
|
|
224
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
|
|
225
|
+
});
|
|
226
|
+
test("explicit timeout on non-delegate also honored", () => {
|
|
227
|
+
assert.equal(effectiveTimeout(role(["read"]), 600), 600);
|
|
228
|
+
});
|
|
213
229
|
});
|
|
214
230
|
|
|
215
231
|
// ── truncateOutput: guards the #2 head+tail fallback ──
|
|
216
232
|
describe("truncateOutput", () => {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
test("adds truncation header with original length", () => {
|
|
234
|
+
const big = "x".repeat(60000);
|
|
235
|
+
const r = truncateOutput(big);
|
|
236
|
+
assert.ok(r.startsWith("[Output truncated"));
|
|
237
|
+
assert.ok(r.includes("60000 chars total"));
|
|
238
|
+
assert.ok(r.includes("[truncated]"));
|
|
239
|
+
});
|
|
240
|
+
test("keeps head and tail, drops the middle", () => {
|
|
241
|
+
// 120000 chars: 40k H + 40k M + 40k T
|
|
242
|
+
const content = "H".repeat(40000) + "M".repeat(40000) + "T".repeat(40000);
|
|
243
|
+
const r = truncateOutput(content);
|
|
244
|
+
assert.ok(r.includes("H"), "head preserved");
|
|
245
|
+
assert.ok(r.includes("T"), "tail preserved");
|
|
246
|
+
assert.ok(!r.includes("M"), "middle dropped");
|
|
247
|
+
});
|
|
232
248
|
});
|
|
233
249
|
|
|
234
250
|
// ── formatTokens: boundary correctness ──
|
|
235
251
|
describe("formatTokens", () => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
test("under 1000 stays raw", () => {
|
|
253
|
+
assert.equal(formatTokens(0), "0");
|
|
254
|
+
assert.equal(formatTokens(999), "999");
|
|
255
|
+
});
|
|
256
|
+
test("1000-9999 with one decimal place", () => {
|
|
257
|
+
assert.equal(formatTokens(1000), "1.0k");
|
|
258
|
+
assert.equal(formatTokens(9500), "9.5k");
|
|
259
|
+
// 9999/1000 = 9.999, toFixed(1) rounds up to 10.0
|
|
260
|
+
assert.equal(formatTokens(9999), "10.0k");
|
|
261
|
+
});
|
|
262
|
+
test("10000-999999 rounded to integer k", () => {
|
|
263
|
+
assert.equal(formatTokens(10000), "10k");
|
|
264
|
+
assert.equal(formatTokens(999999), "1000k");
|
|
265
|
+
});
|
|
266
|
+
test(">= 1000000 in M", () => {
|
|
267
|
+
assert.equal(formatTokens(1000000), "1.0M");
|
|
268
|
+
});
|
|
253
269
|
});
|
|
254
270
|
|
|
255
271
|
// ── elapsedSeconds: live/terminal time derivation ──
|
|
256
272
|
describe("elapsedSeconds", () => {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
test("terminal state: rounds elapsedMs to whole seconds", () => {
|
|
274
|
+
assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 12345 }), 12);
|
|
275
|
+
assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 400 }), 0);
|
|
276
|
+
assert.equal(elapsedSeconds({ exitCode: 1, elapsedMs: 59999 }), 60);
|
|
277
|
+
});
|
|
278
|
+
test("terminal state without elapsedMs -> undefined", () => {
|
|
279
|
+
assert.equal(elapsedSeconds({ exitCode: 0 }), undefined);
|
|
280
|
+
});
|
|
281
|
+
test("queued (running sentinel, no startTime) -> undefined", () => {
|
|
282
|
+
assert.equal(elapsedSeconds({ exitCode: -1 }), undefined);
|
|
283
|
+
});
|
|
284
|
+
test("running: live seconds from startTime (within ~1s drift)", () => {
|
|
285
|
+
const start = Date.now() - 3500;
|
|
286
|
+
const s = elapsedSeconds({ exitCode: -1, startTime: start });
|
|
287
|
+
assert.ok(s !== undefined, "should be defined while running");
|
|
288
|
+
assert.ok(s >= 3 && s <= 4, `expected ~3s, got ${s}`);
|
|
289
|
+
});
|
|
290
|
+
test("running: clamps negative drift (future startTime) to 0", () => {
|
|
291
|
+
const start = Date.now() + 10000; // 10s in the future
|
|
292
|
+
assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
|
|
293
|
+
});
|
|
278
294
|
});
|