@d3ara1n/pi-subagent 0.10.0 → 0.10.2
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 +13 -10
- package/package.json +2 -2
- package/src/config.test.ts +118 -0
- package/src/config.ts +5 -5
- package/src/history.ts +56 -0
- package/src/index.ts +31 -455
- package/src/output.ts +116 -0
- package/src/render.ts +277 -0
- package/src/roles.ts +10 -6
- package/src/spawn.ts +45 -22
- package/src/types.ts +8 -11
- package/src/utils.test.ts +45 -10
- package/src/utils.ts +26 -8
package/src/utils.test.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* node --test packages/pi-subagent/src/utils.test.ts
|
|
6
6
|
*
|
|
7
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
|
|
8
|
+
* path-injection (sanitizeFilename), concurrency/abort/negative-active/unlimited
|
|
9
|
+
* semantics (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
|
|
10
10
|
* formatting (previewArgs), output truncation fallback (truncateOutput).
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -146,6 +146,40 @@ describe("AsyncSemaphore", () => {
|
|
|
146
146
|
await assert.rejects(p);
|
|
147
147
|
assert.equal((s as any).waiters.length, 0);
|
|
148
148
|
});
|
|
149
|
+
test("unlimited max (0) never queues or reports capacity", async () => {
|
|
150
|
+
const s = new AsyncSemaphore(0);
|
|
151
|
+
assert.equal(s.isLimited, false);
|
|
152
|
+
assert.equal(s.isAtCapacity, false);
|
|
153
|
+
|
|
154
|
+
let acquired = 0;
|
|
155
|
+
await Promise.all(
|
|
156
|
+
Array.from({ length: 10 }, () =>
|
|
157
|
+
s.acquire().then(() => {
|
|
158
|
+
acquired++;
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.equal(acquired, 10);
|
|
164
|
+
assert.equal((s as any).waiters.length, 0);
|
|
165
|
+
assert.equal(s.isAtCapacity, false);
|
|
166
|
+
});
|
|
167
|
+
test("positive max reports capacity and retains FIFO queueing", async () => {
|
|
168
|
+
const s = new AsyncSemaphore(1);
|
|
169
|
+
await s.acquire();
|
|
170
|
+
assert.equal(s.isAtCapacity, true);
|
|
171
|
+
|
|
172
|
+
const order: number[] = [];
|
|
173
|
+
const p1 = s.acquire().then(() => order.push(1));
|
|
174
|
+
const p2 = s.acquire().then(() => order.push(2));
|
|
175
|
+
assert.equal((s as any).waiters.length, 2);
|
|
176
|
+
|
|
177
|
+
s.release();
|
|
178
|
+
await p1;
|
|
179
|
+
s.release();
|
|
180
|
+
await p2;
|
|
181
|
+
assert.deepEqual(order, [1, 2]);
|
|
182
|
+
});
|
|
149
183
|
test("releases queued waiters in FIFO order", async () => {
|
|
150
184
|
const s = new AsyncSemaphore(1);
|
|
151
185
|
await s.acquire();
|
|
@@ -214,17 +248,18 @@ describe("effectiveTimeout", () => {
|
|
|
214
248
|
timeout,
|
|
215
249
|
}) as unknown as SubagentRole;
|
|
216
250
|
|
|
217
|
-
test("
|
|
218
|
-
assert.equal(effectiveTimeout(role(["read", "grep"])
|
|
251
|
+
test("role without timeout is unlimited", () => {
|
|
252
|
+
assert.equal(effectiveTimeout(role(["read", "grep"])), 0);
|
|
219
253
|
});
|
|
220
|
-
test("delegate role
|
|
221
|
-
assert.equal(effectiveTimeout(role(["read", "delegate"])
|
|
254
|
+
test("delegate-capable role without timeout is also unlimited", () => {
|
|
255
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"])), 0);
|
|
222
256
|
});
|
|
223
|
-
test("explicit
|
|
224
|
-
assert.equal(effectiveTimeout(role(["read", "delegate"], 300)
|
|
257
|
+
test("explicit role timeout is honored", () => {
|
|
258
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"], 300)), 300);
|
|
225
259
|
});
|
|
226
|
-
test("
|
|
227
|
-
assert.equal(effectiveTimeout(role(["read"]
|
|
260
|
+
test("negative and non-finite values normalize to unlimited", () => {
|
|
261
|
+
assert.equal(effectiveTimeout(role(["read"], -1)), 0);
|
|
262
|
+
assert.equal(effectiveTimeout(role(["read"], Number.POSITIVE_INFINITY)), 0);
|
|
228
263
|
});
|
|
229
264
|
});
|
|
230
265
|
|
package/src/utils.ts
CHANGED
|
@@ -219,11 +219,24 @@ export function previewArgs(args: Record<string, unknown>): string {
|
|
|
219
219
|
return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// ── Numeric configuration ─────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Normalize a finite numeric limit: invalid values use the default; negatives become 0 (unlimited). */
|
|
225
|
+
export function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
|
|
226
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
227
|
+
return Math.max(0, value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Normalize a count limit to a non-negative integer. */
|
|
231
|
+
export function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
|
|
232
|
+
return Math.floor(normalizeNonNegativeNumber(value, fallback));
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
// ── Concurrency gate ───────────────────────────────────────────────
|
|
223
236
|
|
|
224
237
|
/**
|
|
225
238
|
* Promise-based semaphore capping concurrent subagent spawns.
|
|
226
|
-
*
|
|
239
|
+
* A max of 0 means unlimited concurrency, so acquire() never queues.
|
|
227
240
|
* Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
|
|
228
241
|
*/
|
|
229
242
|
export class AsyncSemaphore {
|
|
@@ -231,10 +244,16 @@ export class AsyncSemaphore {
|
|
|
231
244
|
private waiters: Array<() => void> = [];
|
|
232
245
|
private max: number;
|
|
233
246
|
constructor(max: number) {
|
|
234
|
-
this.max = max;
|
|
247
|
+
this.max = normalizeNonNegativeInteger(max, 0);
|
|
248
|
+
}
|
|
249
|
+
get isLimited(): boolean {
|
|
250
|
+
return this.max > 0;
|
|
251
|
+
}
|
|
252
|
+
get isAtCapacity(): boolean {
|
|
253
|
+
return this.isLimited && this.active >= this.max;
|
|
235
254
|
}
|
|
236
255
|
async acquire(signal?: AbortSignal): Promise<void> {
|
|
237
|
-
if (this.
|
|
256
|
+
if (!this.isAtCapacity) {
|
|
238
257
|
this.active++;
|
|
239
258
|
return;
|
|
240
259
|
}
|
|
@@ -262,6 +281,7 @@ export class AsyncSemaphore {
|
|
|
262
281
|
}
|
|
263
282
|
release(): void {
|
|
264
283
|
this.active = Math.max(0, this.active - 1);
|
|
284
|
+
if (!this.isLimited) return;
|
|
265
285
|
const next = this.waiters.shift();
|
|
266
286
|
if (next) next();
|
|
267
287
|
}
|
|
@@ -271,12 +291,10 @@ export class AsyncSemaphore {
|
|
|
271
291
|
|
|
272
292
|
/**
|
|
273
293
|
* Effective per-role timeout in SECONDS (convert to ms at the spawn boundary).
|
|
274
|
-
*
|
|
275
|
-
* pauses while the child is inside a nested `delegate` call, so the base
|
|
276
|
-
* budget is already enough. An explicit roleDef.timeout always wins.
|
|
294
|
+
* `0` or unset means unlimited; non-finite and negative values normalize to 0.
|
|
277
295
|
*/
|
|
278
|
-
export function effectiveTimeout(roleDef: SubagentRole
|
|
279
|
-
return roleDef.timeout
|
|
296
|
+
export function effectiveTimeout(roleDef: SubagentRole): number {
|
|
297
|
+
return normalizeNonNegativeNumber(roleDef.timeout, 0);
|
|
280
298
|
}
|
|
281
299
|
|
|
282
300
|
// ── Output truncation ────────────────────────────────────────
|