@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.13.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 = (runId: number, status: "done" | "failed"): void => {
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
- const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
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") finishRun(runId, e.status);
261
- else monitor.setStatus(runId, e.status);
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
- if (!sessionActive) break;
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: each in-flight run was already finished by its launchInLoop path.
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
- const onLive = makeLiveHandler(runId);
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
- startFixLoop(result, `fix-${runId}`);
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
- lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
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" && run.startedAt === undefined) {
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
- const filePath = join(dir, `${Date.now()}-${safeName}.md`);
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 += data.toString();
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.