@ferris1225/pi-subagents 0.13.0 → 0.14.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 +1 -1
- package/src/index.ts +51 -16
- package/src/monitor.ts +20 -2
- package/src/spawn.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -243,22 +243,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
243
243
|
|
|
244
244
|
// Finished runs leave the widget immediately. Their final findings are sent
|
|
245
245
|
// back as a custom message that automatically starts a follow-up turn.
|
|
246
|
-
const finishRun = (
|
|
246
|
+
const finishRun = (
|
|
247
|
+
runId: number,
|
|
248
|
+
status: "done" | "failed",
|
|
249
|
+
opts?: { silent?: boolean; retain?: boolean },
|
|
250
|
+
): void => {
|
|
247
251
|
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
248
|
-
const run = monitor.removeRun(runId);
|
|
252
|
+
const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
|
|
249
253
|
if (!run) return; // already finished — stay idempotent
|
|
250
|
-
if (!sessionActive) return;
|
|
254
|
+
if (opts?.silent || !sessionActive) return;
|
|
251
255
|
const icon = status === "done" ? "✓" : "✗";
|
|
252
256
|
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
253
257
|
};
|
|
254
258
|
|
|
255
259
|
// Live sub-agent activity → concise one-line status ("thinking",
|
|
256
|
-
// "read src/index.ts", ...), never a raw args blob.
|
|
257
|
-
|
|
260
|
+
// "read src/index.ts", ...), never a raw args blob. Reviewer runs started
|
|
261
|
+
// by the main agent defer finishing so the queue task can decide between
|
|
262
|
+
// delivering the review and starting an auto-fix chain: a triggered chain
|
|
263
|
+
// keeps the parent row in the widget (annotated) until it completes and
|
|
264
|
+
// suppresses the premature "done" notification.
|
|
265
|
+
const makeLiveHandler = (runId: number, deferFinish = false) => (e: SubagentLiveEvent): void => {
|
|
258
266
|
switch (e.kind) {
|
|
259
267
|
case "status":
|
|
260
|
-
if (e.status === "done" || e.status === "failed")
|
|
261
|
-
|
|
268
|
+
if (e.status === "done" || e.status === "failed") {
|
|
269
|
+
// Deferred runs only update the widget; the queue task finishes
|
|
270
|
+
// them once it knows whether an auto-fix chain will follow.
|
|
271
|
+
if (deferFinish) monitor.setStatus(runId, e.status);
|
|
272
|
+
else finishRun(runId, e.status);
|
|
273
|
+
} else monitor.setStatus(runId, e.status);
|
|
262
274
|
break;
|
|
263
275
|
case "usage":
|
|
264
276
|
monitor.setUsage(runId, e.usage, e.model);
|
|
@@ -386,8 +398,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
386
398
|
* findings) → reviewer re-review, up to maxFixRounds times. The main agent is
|
|
387
399
|
* not woken mid-loop; the full chain is delivered as one group at the end.
|
|
388
400
|
* Failures short-circuit: a crashed worker skips its re-review and delivers.
|
|
401
|
+
* The triggering reviewer's run stays visible in the widget (annotated) until
|
|
402
|
+
* the chain resolves, so the ↳ rows have an obvious parent.
|
|
389
403
|
*/
|
|
390
|
-
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string): void => {
|
|
404
|
+
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
|
|
391
405
|
backgroundQueue.enqueue(
|
|
392
406
|
async (signal) => {
|
|
393
407
|
const chain: SingleResult[] = [initialReviewerResult];
|
|
@@ -408,12 +422,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
408
422
|
});
|
|
409
423
|
chain.push(reviewResult);
|
|
410
424
|
lastReviewer = reviewResult;
|
|
411
|
-
|
|
425
|
+
// A crashed re-review must stop the chain like a crashed worker: its
|
|
426
|
+
// output (if any) is not a verdict, and feeding it to the next fix
|
|
427
|
+
// round would brief the worker from garbage.
|
|
428
|
+
if (!sessionActive || isFailedResult(reviewResult)) break;
|
|
412
429
|
if (reviewVerdict(getResultOutput(reviewResult)) === "pass") break;
|
|
413
430
|
}
|
|
431
|
+
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
432
|
+
// parent row, then deliver the whole chain as one group. The loop's
|
|
433
|
+
// outcome always wakes the main agent (a passing chain reports
|
|
434
|
+
// success, a stuck one needs a human).
|
|
435
|
+
monitor.removeRun(parentRunId);
|
|
414
436
|
if (!sessionActive) return;
|
|
415
|
-
// Deliver the whole chain as one group; the loop's outcome always wakes
|
|
416
|
-
// the main agent (a passing chain reports success, a stuck one needs a human).
|
|
417
437
|
const items: CompletionMessageItem[] = chain.map((r) => ({
|
|
418
438
|
agent: r.agent,
|
|
419
439
|
block: formatCompletionBlock(r, config.maxResultLines),
|
|
@@ -423,7 +443,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
423
443
|
completionBatcher.flush();
|
|
424
444
|
},
|
|
425
445
|
() => {
|
|
426
|
-
// Cancelled
|
|
446
|
+
// Cancelled before delivery: clean up the retained parent row (each
|
|
447
|
+
// in-flight chain run was already finished by its launchInLoop path).
|
|
448
|
+
monitor.removeRun(parentRunId);
|
|
427
449
|
},
|
|
428
450
|
);
|
|
429
451
|
};
|
|
@@ -436,7 +458,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
436
458
|
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
437
459
|
const pending = queuedResult(agent, task, thinkingLevel);
|
|
438
460
|
const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
|
|
439
|
-
|
|
461
|
+
// Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
|
|
462
|
+
// only its finish is deferred to the queue task (see startFixLoop).
|
|
463
|
+
const onLive = makeLiveHandler(runId, agent.name === "reviewer");
|
|
440
464
|
|
|
441
465
|
backgroundQueue.enqueue(
|
|
442
466
|
async (backgroundSignal) => {
|
|
@@ -473,12 +497,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
473
497
|
// triggers a worker→reviewer chain (up to maxFixRounds) without waking
|
|
474
498
|
// the main agent. Loop-internal re-reviews never reach here (they are
|
|
475
499
|
// awaited inside launchInLoop); the initial review is delivered with
|
|
476
|
-
// the chain at the end.
|
|
500
|
+
// the chain at the end. While the chain runs, the triggering review
|
|
501
|
+
// stays in the widget (annotated) so the chain rows have an obvious
|
|
502
|
+
// parent; no premature "done" notification is shown.
|
|
477
503
|
if (shouldTriggerFixLoop(result, config)) {
|
|
478
|
-
|
|
504
|
+
// The session is known active here (checked above), so the chain
|
|
505
|
+
// always starts: keep the triggering review in the widget
|
|
506
|
+
// (annotated) without a premature "done" notification, and let
|
|
507
|
+
// startFixLoop deliver the whole chain and drop the parent row.
|
|
508
|
+
finishRun(runId, "done", { silent: true, retain: true });
|
|
509
|
+
monitor.setAnnotation(runId, "auto-fix chain running");
|
|
510
|
+
startFixLoop(result, `fix-${runId}`, runId);
|
|
479
511
|
return;
|
|
480
512
|
}
|
|
481
513
|
const failed = isFailedResult(result);
|
|
514
|
+
finishRun(runId, failed ? "failed" : "done");
|
|
515
|
+
if (!sessionActive) return;
|
|
482
516
|
const completion: CompletionMessageItem = {
|
|
483
517
|
agent: result.agent,
|
|
484
518
|
block: formatCompletionBlock(result, config.maxResultLines),
|
|
@@ -628,7 +662,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
628
662
|
// Chain-internal runs (auto-fix worker/reviewer) indent under their
|
|
629
663
|
// parent reviewer; summarize() already carries the relationLabel.
|
|
630
664
|
const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
|
|
631
|
-
|
|
665
|
+
const note = r.annotation ? theme.fg("dim", ` · ${r.annotation}`) : "";
|
|
666
|
+
lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
|
|
632
667
|
if (r.status === "queued" || r.status === "running") {
|
|
633
668
|
lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
|
|
634
669
|
}
|
package/src/monitor.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface RunView {
|
|
|
40
40
|
groupId?: string;
|
|
41
41
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
42
42
|
relationLabel?: string;
|
|
43
|
+
/** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
|
|
44
|
+
annotation?: string;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Optional chain metadata for runs spawned by an auto-fix loop. */
|
|
@@ -199,8 +201,11 @@ export class MonitorStore {
|
|
|
199
201
|
const run = this.find(id);
|
|
200
202
|
if (!run) return;
|
|
201
203
|
run.status = status;
|
|
202
|
-
if (status === "running"
|
|
203
|
-
run.startedAt = Date.now();
|
|
204
|
+
if (status === "running") {
|
|
205
|
+
if (run.startedAt === undefined) run.startedAt = Date.now();
|
|
206
|
+
// A model-fallback retry after a failed attempt restarts the clock; a
|
|
207
|
+
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
208
|
+
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
204
209
|
} else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
205
210
|
run.endedAt = Date.now();
|
|
206
211
|
}
|
|
@@ -222,6 +227,19 @@ export class MonitorStore {
|
|
|
222
227
|
this.notify();
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
/** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
|
|
231
|
+
setAnnotation(id: number, text: string): void {
|
|
232
|
+
const run = this.find(id);
|
|
233
|
+
if (!run) return;
|
|
234
|
+
run.annotation = text;
|
|
235
|
+
this.notify();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Look up a run by id without removing it. */
|
|
239
|
+
findRun(id: number): RunView | undefined {
|
|
240
|
+
return this.find(id);
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
/** Remove a run (finished runs leave the widget). Returns the removed run. */
|
|
226
244
|
removeRun(id: number): RunView | undefined {
|
|
227
245
|
const index = this.runs.findIndex((r) => r.id === id);
|
package/src/spawn.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "nod
|
|
|
15
15
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
18
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
19
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
19
20
|
import type { Message } from "@earendil-works/pi-ai";
|
|
20
21
|
import type { AgentConfig, AgentSource } from "./agents.ts";
|
|
@@ -134,7 +135,9 @@ export function writeResultArtifact(output: string, agentName: string): string {
|
|
|
134
135
|
const dir = join(tmpdir(), "pi-subagents-results");
|
|
135
136
|
mkdirSync(dir, { recursive: true });
|
|
136
137
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
137
|
-
|
|
138
|
+
// A random suffix keeps same-millisecond writes from clobbering each other.
|
|
139
|
+
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
140
|
+
const filePath = join(dir, `${unique}-${safeName}.md`);
|
|
138
141
|
writeFileSync(filePath, output, "utf8");
|
|
139
142
|
return filePath;
|
|
140
143
|
}
|
|
@@ -457,8 +460,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
457
460
|
proc.stdin?.on("error", () => undefined);
|
|
458
461
|
proc.stdin?.end(`Task: ${task}`);
|
|
459
462
|
|
|
463
|
+
// Decode stdout through a StringDecoder so multi-byte UTF-8 characters
|
|
464
|
+
// (CJK, emoji) split across chunk boundaries never produce U+FFFD
|
|
465
|
+
// replacement characters — a corrupted JSON line would drop the whole
|
|
466
|
+
// message (including a reviewer's verdict line) from parsing.
|
|
467
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
460
468
|
proc.stdout.on("data", (data) => {
|
|
461
|
-
buffer +=
|
|
469
|
+
buffer += stdoutDecoder.write(data);
|
|
462
470
|
const lines = buffer.split("\n");
|
|
463
471
|
buffer = lines.pop() || "";
|
|
464
472
|
for (const line of lines) processLine(line);
|
|
@@ -469,6 +477,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
469
477
|
});
|
|
470
478
|
|
|
471
479
|
proc.on("close", (code) => {
|
|
480
|
+
// Flush any bytes still held by the decoder (a trailing incomplete
|
|
481
|
+
// multi-byte sequence) before processing the final buffer.
|
|
482
|
+
buffer += stdoutDecoder.end();
|
|
472
483
|
if (buffer.trim()) processLine(buffer);
|
|
473
484
|
// A null exit code means the process was terminated by a signal and
|
|
474
485
|
// must be reported as failure, never as a false clean completion.
|