@ferris1225/pi-subagents 0.13.0 → 0.15.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.15.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
@@ -134,7 +134,7 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number): st
134
134
  const fallbackNote = result.modelFallbackFrom
135
135
  ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
136
136
  : "";
137
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task)}`, "", text];
137
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
138
138
  if (truncated) {
139
139
  // The full text lives on disk so the main agent can read it on demand.
140
140
  lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
@@ -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),
@@ -553,14 +587,14 @@ export default function (pi: ExtensionAPI): void {
553
587
  if (args.tasks && args.tasks.length > 0) {
554
588
  let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
555
589
  for (const t of args.tasks.slice(0, 4)) {
556
- const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
590
+ const preview = formatTaskSummary(t.task, 48);
557
591
  text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
558
592
  }
559
593
  if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
560
594
  return new Text(text, 0, 0);
561
595
  }
562
596
  const task: string = args.task ?? "";
563
- const preview = task.length > 60 ? `${task.slice(0, 60)}…` : task;
597
+ const preview = formatTaskSummary(task, 60);
564
598
  return new Text(
565
599
  `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
566
600
  0,
@@ -628,9 +662,10 @@ 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} #${r.id} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
632
667
  if (r.status === "queued" || r.status === "running") {
633
- lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
668
+ lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task, Math.max(20, width - 11))}`), width, ""));
634
669
  }
635
670
  // Activity sits one indent level below the agent name.
636
671
  if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
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. */
@@ -54,23 +56,145 @@ export interface RunChainMeta {
54
56
 
55
57
  const TASK_SUMMARY_MAX = 80;
56
58
  const TASK_SUMMARY_ELLIPSIS = "…";
59
+ /** Columns reserved at the END of a truncated summary so the distinguishing
60
+ * keywords (paths, symbols, ...) survive; the head gets the rest. */
61
+ const TASK_SUMMARY_TAIL_MAX = 28;
62
+ /** Tail share of a non-default maxWidth (narrow widgets keep a usable tail). */
63
+ const TASK_SUMMARY_TAIL_SHARE = 0.35;
64
+ const TASK_SUMMARY_TAIL_MIN = 8;
65
+ const TASK_SUMMARY_KEY_SEP = " · ";
66
+ /** kebab/snake words that are task boilerplate, never distinguishing signal. */
67
+ const KEY_FRAGMENT_STOPWORDS = new Set([
68
+ "self-contained",
69
+ "read-only",
70
+ "write-only",
71
+ "auto-fix",
72
+ "re-review",
73
+ "one-line",
74
+ "pre-commit",
75
+ ]);
57
76
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
58
77
 
59
- /** One-line task preview, capped by terminal display columns (including the ellipsis). */
60
- export function formatTaskSummary(task: string): string {
61
- const oneLine = stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
62
- if (visibleWidth(oneLine) <= TASK_SUMMARY_MAX) return oneLine;
78
+ interface KeyFragment {
79
+ text: string;
80
+ index: number;
81
+ }
63
82
 
64
- const prefixMax = TASK_SUMMARY_MAX - visibleWidth(TASK_SUMMARY_ELLIPSIS);
65
- let prefix = "";
66
- let prefixWidth = 0;
67
- for (const { segment } of graphemeSegmenter.segment(oneLine)) {
83
+ /**
84
+ * Pull the most distinguishing fragments out of a task: file paths, quoted
85
+ * phrases, camelCase/PascalCase symbols and kebab/snake compounds. Sorted by
86
+ * first occurrence and deduped (a path covers its own sub-fragments). These
87
+ * are what make parallel tasks of the same agent look different.
88
+ */
89
+ export function extractKeyFragments(text: string): string[] {
90
+ const fragments: KeyFragment[] = [];
91
+ const add = (re: RegExp, group = 0): void => {
92
+ for (const m of text.matchAll(re)) {
93
+ const g = m[group];
94
+ if (g === undefined) continue;
95
+ fragments.push({ text: g, index: m.index ?? 0 });
96
+ }
97
+ };
98
+ // Quoted phrases first (highest signal).
99
+ add(/["'`]([^"'`]{4,60})["'`]/g, 1);
100
+ // Paths with a known extension (src/index.ts, build/out.js.map).
101
+ add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,5}(?![A-Za-z0-9_.-])/g);
102
+ // Paths with a slash but no extension (src/components, .github/workflows).
103
+ add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)+(?![\\/])/g);
104
+ // camelCase / PascalCase identifiers (function or type names).
105
+ add(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g);
106
+ // snake_case / kebab-case compound words.
107
+ add(/\b[a-z][a-z0-9]+[-_][a-z0-9][a-z0-9_-]*\b/g);
108
+
109
+ fragments.sort((a, b) => a.index - b.index);
110
+ const seen = new Set<string>();
111
+ const out: string[] = [];
112
+ for (const f of fragments) {
113
+ const t = f.text.trim();
114
+ if (t.length < 4 || KEY_FRAGMENT_STOPWORDS.has(t)) continue;
115
+ if (seen.has(t)) continue;
116
+ // A longer fragment (the full path) covers its own sub-fragments.
117
+ if (out.some((o) => o.includes(t) || t.includes(o))) continue;
118
+ seen.add(t);
119
+ out.push(t);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function takeGraphemes(segments: string[], maxWidth: number): string {
125
+ let width = 0;
126
+ const out: string[] = [];
127
+ for (const segment of segments) {
68
128
  const segmentWidth = visibleWidth(segment);
69
- if (prefixWidth + segmentWidth > prefixMax) break;
70
- prefix += segment;
71
- prefixWidth += segmentWidth;
129
+ if (width + segmentWidth > maxWidth) break;
130
+ out.push(segment);
131
+ width += segmentWidth;
132
+ }
133
+ return out.join("");
134
+ }
135
+
136
+ function tailGraphemes(segments: string[], maxWidth: number): string {
137
+ let width = 0;
138
+ const tail: string[] = [];
139
+ for (let i = segments.length - 1; i >= 0; i--) {
140
+ const segmentWidth = visibleWidth(segments[i]);
141
+ if (width + segmentWidth > maxWidth) break;
142
+ tail.unshift(segments[i]);
143
+ width += segmentWidth;
72
144
  }
73
- return `${prefix}${TASK_SUMMARY_ELLIPSIS}`;
145
+ return tail.join("");
146
+ }
147
+
148
+ /**
149
+ * One-line task preview, capped by `maxWidth` display columns (default 80).
150
+ * `keysOnly` (default): extracted key fragments (paths, quoted phrases,
151
+ * symbols) are shown bare — the agent name is already displayed next to the
152
+ * task line, so templated prose ("explore: trace how ...") adds nothing.
153
+ * `keysOnly: false` keeps the prose as `head…tail` (used for completion
154
+ * messages, where the Task line is the reader's only context).
155
+ * Grapheme-safe — CJK, ZWJ emoji and combining sequences are never split.
156
+ */
157
+ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_MAX, keysOnly = true): string {
158
+ const oneLine = stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
159
+ if (maxWidth <= 0 || visibleWidth(oneLine) <= maxWidth) return oneLine;
160
+
161
+ const segments = [...graphemeSegmenter.segment(oneLine)].map((s) => s.segment);
162
+ const ellipsisWidth = visibleWidth(TASK_SUMMARY_ELLIPSIS);
163
+
164
+ if (keysOnly) {
165
+ const fragments = extractKeyFragments(oneLine);
166
+ if (fragments.length > 0) {
167
+ const keyMax = maxWidth - 1;
168
+ let keys = "";
169
+ for (const fragment of fragments) {
170
+ const piece = keys ? `${TASK_SUMMARY_KEY_SEP}${fragment}` : fragment;
171
+ const total = keys + piece;
172
+ if (visibleWidth(total) > keyMax) {
173
+ // Budget exhausted: keep what fits, unless nothing fits yet.
174
+ if (!keys) {
175
+ // A single over-long fragment keeps its tail
176
+ // (extension/symbol) and is prefixed with the ellipsis.
177
+ const fragmentSegments = [...graphemeSegmenter.segment(piece)].map((s) => s.segment);
178
+ keys = `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(fragmentSegments, keyMax - ellipsisWidth)}`;
179
+ }
180
+ break;
181
+ }
182
+ keys = total;
183
+ }
184
+ return keys;
185
+ }
186
+ }
187
+
188
+ // No distinctive fragments (or prose mode): fall back to head…tail.
189
+ const tailMax = Math.max(
190
+ TASK_SUMMARY_TAIL_MIN,
191
+ Math.min(TASK_SUMMARY_TAIL_MAX, Math.round(maxWidth * TASK_SUMMARY_TAIL_SHARE)),
192
+ );
193
+ const headMax = maxWidth - ellipsisWidth - tailMax;
194
+ if (headMax <= 0) {
195
+ return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, maxWidth - ellipsisWidth)}`;
196
+ }
197
+ return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
74
198
  }
75
199
 
76
200
  function formatTokens(count: number): string {
@@ -199,8 +323,11 @@ export class MonitorStore {
199
323
  const run = this.find(id);
200
324
  if (!run) return;
201
325
  run.status = status;
202
- if (status === "running" && run.startedAt === undefined) {
203
- run.startedAt = Date.now();
326
+ if (status === "running") {
327
+ if (run.startedAt === undefined) run.startedAt = Date.now();
328
+ // A model-fallback retry after a failed attempt restarts the clock; a
329
+ // stale endedAt would freeze the elapsed display at the first attempt.
330
+ if (run.endedAt !== undefined) run.endedAt = undefined;
204
331
  } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
205
332
  run.endedAt = Date.now();
206
333
  }
@@ -222,6 +349,19 @@ export class MonitorStore {
222
349
  this.notify();
223
350
  }
224
351
 
352
+ /** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
353
+ setAnnotation(id: number, text: string): void {
354
+ const run = this.find(id);
355
+ if (!run) return;
356
+ run.annotation = text;
357
+ this.notify();
358
+ }
359
+
360
+ /** Look up a run by id without removing it. */
361
+ findRun(id: number): RunView | undefined {
362
+ return this.find(id);
363
+ }
364
+
225
365
  /** Remove a run (finished runs leave the widget). Returns the removed run. */
226
366
  removeRun(id: number): RunView | undefined {
227
367
  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.