@ferris1225/pi-subagents 0.16.1 → 0.18.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.16.1",
3
+ "version": "0.18.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/background.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Tasks get their own AbortSignal rather than inheriting the foreground agent
5
5
  * turn's signal. The owning extension cancels all work only on session teardown.
6
+ *
7
+ * Task exceptions are never swallowed: the per-task onError callback receives
8
+ * them (unless the task was cancelled) so callers can surface the failure to
9
+ * the user and the main agent instead of it vanishing into the queue.
6
10
  */
7
11
 
8
12
  export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
@@ -11,6 +15,9 @@ interface PendingTask {
11
15
  task: BackgroundTask;
12
16
  controller: AbortController;
13
17
  onCancelled?: () => void;
18
+ /** Invoked when the task throws and was not cancelled (cancellation is not a
19
+ * failure — e.g. session shutdown races must never be reported as errors). */
20
+ onError?: (error: unknown) => void;
14
21
  }
15
22
 
16
23
  export class BackgroundTaskQueue {
@@ -33,15 +40,15 @@ export class BackgroundTaskQueue {
33
40
  this.drain();
34
41
  }
35
42
 
36
- enqueue(task: BackgroundTask, onCancelled?: () => void): AbortController {
43
+ enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void): AbortController {
37
44
  const controller = new AbortController();
38
45
  if (this.stopped) {
39
46
  controller.abort();
40
- onCancelled?.();
47
+ this.runCancelled(onCancelled);
41
48
  return controller;
42
49
  }
43
50
 
44
- this.pending.push({ task, controller, onCancelled });
51
+ this.pending.push({ task, controller, onCancelled, onError });
45
52
  this.drain();
46
53
  return controller;
47
54
  }
@@ -53,25 +60,47 @@ export class BackgroundTaskQueue {
53
60
 
54
61
  for (const entry of this.pending.splice(0)) {
55
62
  entry.controller.abort();
56
- entry.onCancelled?.();
63
+ this.runCancelled(entry.onCancelled);
57
64
  }
58
65
  for (const controller of this.active) controller.abort();
59
66
  }
60
67
 
68
+ /** Cancellation callbacks are user-supplied: a throw must never break the queue
69
+ * (mirrors the try/catch around onError in drain). */
70
+ private runCancelled(callback: (() => void) | undefined): void {
71
+ if (!callback) return;
72
+ try {
73
+ callback();
74
+ } catch {
75
+ /* cancellation callbacks must never break the queue */
76
+ }
77
+ }
78
+
61
79
  private drain(): void {
62
80
  while (!this.stopped && this.active.size < this.concurrency) {
63
81
  const entry = this.pending.shift();
64
82
  if (!entry) return;
65
83
  if (entry.controller.signal.aborted) {
66
- entry.onCancelled?.();
84
+ this.runCancelled(entry.onCancelled);
67
85
  continue;
68
86
  }
69
87
 
70
88
  this.active.add(entry.controller);
71
- void entry.task(entry.controller.signal).catch(() => undefined).finally(() => {
72
- this.active.delete(entry.controller);
73
- this.drain();
74
- });
89
+ void entry.task(entry.controller.signal)
90
+ .catch((error: unknown) => {
91
+ // Cancellation is not a failure: aborted work (e.g. session
92
+ // shutdown) must never be reported as an exception.
93
+ if (entry.controller.signal.aborted) return;
94
+ try {
95
+ entry.onError?.(error);
96
+ } catch {
97
+ /* error reporting must never break the queue */
98
+ }
99
+ })
100
+ .finally(() => {
101
+ this.active.delete(entry.controller);
102
+ this.drain();
103
+ });
75
104
  }
76
105
  }
77
106
  }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  currentSubagentDepth,
33
33
  getResultOutput,
34
34
  isFailedResult,
35
+ isModelLevelFailure,
35
36
  reviewVerdict,
36
37
  runSingleAgentWithModelFallback,
37
38
  truncateResultOutput,
@@ -92,6 +93,21 @@ function failedStartResult(agentName: string, task: string, errorMessage: string
92
93
  stderr: errorMessage,
93
94
  usage: emptyUsage(),
94
95
  errorMessage,
96
+ dispatchFailed: true,
97
+ };
98
+ }
99
+
100
+ /** Failed result for a background task that crashed with an exception (spawn
101
+ * infra, delivery API, ...) instead of returning a normal result. */
102
+ function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
103
+ const errorMessage = error instanceof Error ? error.message : String(error);
104
+ return {
105
+ ...queuedResult(agent, task, thinking),
106
+ exitCode: 1,
107
+ stderr: errorMessage,
108
+ stopReason: "error",
109
+ errorMessage,
110
+ dispatchFailed: true,
95
111
  };
96
112
  }
97
113
 
@@ -124,7 +140,7 @@ function formatUsage(usage: UsageStats): string {
124
140
  return parts.join(" ");
125
141
  }
126
142
 
127
- function formatCompletionBlock(result: SingleResult, maxResultLines: number): string {
143
+ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
128
144
  const status = isFailedResult(result) ? "failed" : "completed";
129
145
  const usage = formatUsage(result.usage);
130
146
  const output = getResultOutput(result);
@@ -135,11 +151,19 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number): st
135
151
  const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
136
152
  if (truncated) {
137
153
  // The full text lives on disk so the main agent can read it on demand.
138
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
154
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
139
155
  }
140
156
  return lines.join("\n");
141
157
  }
142
158
 
159
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
160
+ * produced usable output (or the run stalled), so the task is handed back to the
161
+ * main window instead of being left as a dead failure. */
162
+ function modelLevelTakeoverNote(result: SingleResult): string {
163
+ const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
164
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
165
+ }
166
+
143
167
  export default function (pi: ExtensionAPI): void {
144
168
  const configPath = getConfigPath(getAgentDir());
145
169
  // Init-time decisions need the config synchronously; the full (migrating)
@@ -191,6 +215,9 @@ export default function (pi: ExtensionAPI): void {
191
215
  sessionActive = false;
192
216
  completionBatcher.dispose();
193
217
  backgroundQueue.cancelAll();
218
+ // Clear the monitor so stale runs from this session never leak into the
219
+ // next one (the module-level singleton survives across sessions).
220
+ monitor.clear();
194
221
  });
195
222
 
196
223
  pi.registerTool({
@@ -212,6 +239,7 @@ export default function (pi: ExtensionAPI): void {
212
239
  "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
213
240
  "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
214
241
  "Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
242
+ "NEVER sleep, wait, poll, or call other tools alongside subagent — it ends the turn immediately. The main agent is auto-resumed when results arrive; manual waiting only blocks the turn and delays delivery.",
215
243
  ],
216
244
  parameters: SubagentParams,
217
245
 
@@ -249,6 +277,7 @@ export default function (pi: ExtensionAPI): void {
249
277
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
250
278
  const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
251
279
  if (!run) return; // already finished — stay idempotent
280
+ if (opts?.retain) monitor.setRetained(runId, true);
252
281
  if (opts?.silent || !sessionActive) return;
253
282
  const icon = status === "done" ? "✓" : "✗";
254
283
  ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
@@ -388,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
388
417
  stderr: errorMessage,
389
418
  stopReason: signal.aborted ? "aborted" : "error",
390
419
  errorMessage,
420
+ dispatchFailed: true,
391
421
  };
392
422
  }
393
423
  };
@@ -433,11 +463,20 @@ export default function (pi: ExtensionAPI): void {
433
463
  // success, a stuck one needs a human).
434
464
  monitor.removeRun(parentRunId);
435
465
  if (!sessionActive) return;
436
- const items: CompletionMessageItem[] = chain.map((r) => ({
437
- agent: r.agent,
438
- block: formatCompletionBlock(r, config.maxResultLines),
439
- triggerTurn: true,
440
- }));
466
+ const items: CompletionMessageItem[] = chain.map((r) => {
467
+ // A model-level chain run (worker or re-review whose provider never
468
+ // produced output) is handed to the main window like any other
469
+ // sub-agent run: the block carries the takeover note. Dispatch
470
+ // crashes (dispatchFailed) are excluded by the gate itself.
471
+ const modelLevel = isFailedResult(r) && isModelLevelFailure(r);
472
+ return {
473
+ agent: r.agent,
474
+ block: modelLevel
475
+ ? `${formatCompletionBlock(r, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(r)}`
476
+ : formatCompletionBlock(r, config.maxResultLines, ctx.cwd),
477
+ triggerTurn: true,
478
+ };
479
+ });
441
480
  sendCompletionGroup(items);
442
481
  completionBatcher.flush();
443
482
  },
@@ -446,6 +485,30 @@ export default function (pi: ExtensionAPI): void {
446
485
  // in-flight chain run was already finished by its launchInLoop path).
447
486
  monitor.removeRun(parentRunId);
448
487
  },
488
+ (error) => {
489
+ // A crash inside the chain orchestration (failed runs are caught by
490
+ // launchInLoop and delivered as part of the chain) must not vanish:
491
+ // drop the retained parent row, notify, and deliver a failed result
492
+ // so the main agent knows the chain never completed.
493
+ monitor.removeRun(parentRunId);
494
+ if (!sessionActive) return;
495
+ const errorMessage = error instanceof Error ? error.message : String(error);
496
+ try {
497
+ ctx.ui.notify(`✗ auto-fix chain 派发失败: ${errorMessage}`, "error");
498
+ // Keep the triggering review's findings: the chain crashed before any
499
+ // fix round ran, and the main agent needs the review to act on it.
500
+ sendCompletionGroup([
501
+ {
502
+ agent: initialReviewerResult.agent,
503
+ block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, ctx.cwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
504
+ triggerTurn: true,
505
+ },
506
+ ]);
507
+ completionBatcher.flush();
508
+ } catch {
509
+ /* a second delivery failure must not throw through the queue */
510
+ }
511
+ },
449
512
  );
450
513
  };
451
514
 
@@ -488,8 +551,11 @@ export default function (pi: ExtensionAPI): void {
488
551
  stderr: errorMessage,
489
552
  stopReason: backgroundSignal.aborted ? "aborted" : "error",
490
553
  errorMessage,
554
+ dispatchFailed: true,
491
555
  };
492
- finishRun(runId, "failed");
556
+ // The dedicated 派发失败 notification below replaces the generic
557
+ // failure toast for dispatch crashes, so finish silently here.
558
+ finishRun(runId, "failed", { silent: true });
493
559
  }
494
560
 
495
561
  if (!sessionActive) return;
@@ -511,13 +577,31 @@ export default function (pi: ExtensionAPI): void {
511
577
  return;
512
578
  }
513
579
  const failed = isFailedResult(result);
514
- finishRun(runId, failed ? "failed" : "done");
580
+ // Model-level failures and dispatch crashes get their own dedicated
581
+ // 派发失败 notification below, so finishRun's generic failure toast is
582
+ // silenced for them (computed before finishRun for that reason).
583
+ const modelLevel = failed && isModelLevelFailure(result);
584
+ const dispatchFailed = result.dispatchFailed === true;
585
+ finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
515
586
  if (!sessionActive) return;
587
+ // Model-level failure: the configured model is unavailable or broke
588
+ // and the retry with the main-window model (when distinct) also
589
+ // failed. Instead of leaving a dead failure, hand the task to the
590
+ // main window — the main agent executes it itself with its own tools.
516
591
  const completion: CompletionMessageItem = {
517
592
  agent: result.agent,
518
- block: formatCompletionBlock(result, config.maxResultLines),
593
+ block: modelLevel
594
+ ? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
595
+ : formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
519
596
  triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
520
597
  };
598
+ if (modelLevel) {
599
+ ctx.ui.notify(`✗ ${result.agent} 派发失败: 模型不可用或出错,任务已交由主窗口执行`, "error");
600
+ } else if (dispatchFailed) {
601
+ // An exception inside the dispatch layer (spawn infra, temp-file/fs
602
+ // errors, ...): the main agent must know so it can re-dispatch.
603
+ ctx.ui.notify(`✗ ${result.agent} 派发失败: ${result.errorMessage ?? "dispatch crashed"}`, "error");
604
+ }
521
605
  if (failed) {
522
606
  // Failures never wait and never hide behind a success turn: deliver
523
607
  // first so the wake-up leads with the failure; held successes follow.
@@ -528,6 +612,28 @@ export default function (pi: ExtensionAPI): void {
528
612
  }
529
613
  },
530
614
  () => finishRun(runId, "failed"),
615
+ (error) => {
616
+ // The task body converts sub-agent failures into delivered results; an
617
+ // exception escaping it (spawn infra, delivery API, ...) must not
618
+ // vanish: notify the user and deliver a failed result so the main
619
+ // agent knows the dispatch failed and can re-dispatch.
620
+ const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
621
+ finishRun(runId, "failed", { silent: true });
622
+ if (!sessionActive) return;
623
+ try {
624
+ ctx.ui.notify(`✗ ${agent.name} 派发失败: ${crashed.errorMessage}`, "error");
625
+ sendCompletionGroup([
626
+ {
627
+ agent: agent.name,
628
+ block: formatCompletionBlock(crashed, config.maxResultLines, ctx.cwd),
629
+ triggerTurn: true,
630
+ },
631
+ ]);
632
+ completionBatcher.flush();
633
+ } catch {
634
+ /* a second delivery failure must not throw through the queue */
635
+ }
636
+ },
531
637
  );
532
638
 
533
639
  return pending;
package/src/monitor.ts CHANGED
@@ -42,6 +42,10 @@ export interface RunView {
42
42
  relationLabel?: string;
43
43
  /** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
44
44
  annotation?: string;
45
+ /** True when a finished run is intentionally kept in the widget (e.g. an
46
+ * auto-fix chain parent whose chain is still running). beginTurn preserves
47
+ * retained runs so they are not swept between turns. */
48
+ retained?: boolean;
45
49
  }
46
50
 
47
51
  /** Optional chain metadata for runs spawned by an auto-fix loop. */
@@ -298,7 +302,12 @@ export class MonitorStore {
298
302
  beginTurn(): void {
299
303
  // Clear finished runs from a previous turn, but keep any still-active
300
304
  // (queued/running) ones so a concurrent sub-agent call is not wiped.
301
- this.runs = this.runs.filter((r) => r.status === "queued" || r.status === "running");
305
+ // Retained runs (e.g. an auto-fix chain parent whose chain is still
306
+ // running) are also preserved — their status is "done" but they must
307
+ // stay visible until the chain resolves.
308
+ this.runs = this.runs.filter(
309
+ (r) => r.status === "queued" || r.status === "running" || r.retained,
310
+ );
302
311
  this.notify();
303
312
  }
304
313
 
@@ -357,11 +366,27 @@ export class MonitorStore {
357
366
  this.notify();
358
367
  }
359
368
 
369
+ /** Mark a run as retained (kept in the widget despite being finished). */
370
+ setRetained(id: number, retained: boolean): void {
371
+ const run = this.find(id);
372
+ if (!run) return;
373
+ run.retained = retained;
374
+ this.notify();
375
+ }
376
+
360
377
  /** Look up a run by id without removing it. */
361
378
  findRun(id: number): RunView | undefined {
362
379
  return this.find(id);
363
380
  }
364
381
 
382
+ /** Remove all runs (used on session shutdown so stale state never leaks
383
+ * into the next session). Does not reset the id counter so in-flight
384
+ * finishRun calls from the old session remain safe no-ops. */
385
+ clear(): void {
386
+ this.runs = [];
387
+ this.notify();
388
+ }
389
+
365
390
  /** Remove a run (finished runs leave the widget). Returns the removed run. */
366
391
  removeRun(id: number): RunView | undefined {
367
392
  const index = this.runs.findIndex((r) => r.id === id);
package/src/prompt.ts CHANGED
@@ -41,6 +41,10 @@ It immediately ends the current main-agent turn so the user can keep working. Wh
41
41
  finishes, its result is sent back as a message that automatically resumes the main agent;
42
42
  if the main agent is busy, the result waits as a follow-up.
43
43
 
44
+ NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout) to wait for
45
+ a sub-agent — the turn already ended and the main agent is auto-resumed when results arrive.
46
+ Manual waiting blocks the turn, delays result delivery, and wastes the user's time.
47
+
44
48
  Available agents:
45
49
  ${catalog}
46
50
 
package/src/spawn.ts CHANGED
@@ -57,6 +57,10 @@ export interface SingleResult {
57
57
  errorMessage?: string;
58
58
  /** Model the run degraded from: set when a failed run was retried with the main-window model. */
59
59
  modelFallbackFrom?: string;
60
+ /** True when the result was synthesized from a thrown exception (spawn infra,
61
+ * temp-file/fs errors, delivery bugs) instead of being produced by the agent
62
+ * process. A dispatch failure is never a model-level failure. */
63
+ dispatchFailed?: boolean;
60
64
  }
61
65
 
62
66
  export interface SubagentDetails {
@@ -128,9 +132,14 @@ export function truncateResultOutput(output: string, maxLines: number): Truncate
128
132
  return { text: kept.join("\n"), truncated: true };
129
133
  }
130
134
 
131
- /** Persist the full result where the main agent can read it on demand. Returns the file path. */
132
- export function writeResultArtifact(output: string, agentName: string): string {
133
- const dir = join(tmpdir(), "pi-subagents-results");
135
+ /** Persist the full result where the main agent can read it on demand. Returns the file path.
136
+ * Results are grouped under a per-project subdirectory so concurrent projects don't
137
+ * litter a single flat folder. */
138
+ export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
139
+ const projectSlug = cwd
140
+ ? basename(cwd).replace(/[^\w.-]+/g, "_") || "default"
141
+ : "default";
142
+ const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
134
143
  mkdirSync(dir, { recursive: true });
135
144
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
136
145
  // A random suffix keeps same-millisecond writes from clobbering each other.
@@ -153,6 +162,10 @@ export function isFailedResult(result: SingleResult): boolean {
153
162
  export function isModelLevelFailure(result: SingleResult): boolean {
154
163
  if (!isFailedResult(result)) return false;
155
164
  if (result.stopReason === "aborted") return false;
165
+ // A result synthesized from a thrown exception (spawn infra, fs, delivery
166
+ // bugs) never came from the provider: it is a dispatch failure, not a
167
+ // model-level one, and must not be handed back as a model problem.
168
+ if (result.dispatchFailed) return false;
156
169
  // An idle timeout (stdout went silent) signals a stalled provider connection,
157
170
  // not a task-level failure: allow model fallback even if the model produced
158
171
  // partial output before going quiet.
@@ -166,7 +179,10 @@ export function isModelLevelFailure(result: SingleResult): boolean {
166
179
 
167
180
  export function getResultOutput(result: SingleResult): string {
168
181
  if (isFailedResult(result)) {
169
- return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
182
+ const error = result.errorMessage || result.stderr;
183
+ const partial = getFinalOutput(result.messages);
184
+ if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
185
+ return error || partial || "(no output)";
170
186
  }
171
187
  return getFinalOutput(result.messages) || "(no output)";
172
188
  }
@@ -506,12 +522,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
506
522
 
507
523
  currentResult.exitCode = exitCode;
508
524
  if (wasAborted) {
525
+ currentResult.stopReason = "aborted";
526
+ currentResult.errorMessage ??= "Subagent was aborted";
509
527
  if (onLive) {
510
528
  try {
511
529
  onLive({ kind: "status", status: "failed" });
512
530
  } catch { /* never throw from event handling */ }
513
531
  }
514
- throw new Error("Subagent was aborted");
515
532
  }
516
533
  return currentResult;
517
534
  } finally {