@ferris1225/pi-subagents 0.17.0 → 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/README.md CHANGED
@@ -389,6 +389,13 @@ for `idleTimeoutSec` seconds) are treated as model-level failures and do trigger
389
389
  since a stalled SSE stream is usually a provider-side issue. Results carry a `model fell back
390
390
  from …` note when it happened.
391
391
 
392
+ If the model is unavailable or broken and the fallback retry also fails (or no fallback model
393
+ is available), the task is **handed back to the main window**: the completion message tells
394
+ the main agent to execute the task itself with its own tools instead of leaving a dead
395
+ failure. A background task that crashes with an exception (spawn infra, delivery API, ...)
396
+ is never silently swallowed either — the user gets a `✗ … 派发失败` error notification and
397
+ the failure is delivered to the main agent, which can re-dispatch it.
398
+
392
399
  Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
393
400
 
394
401
  ## Agent discovery and overrides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.17.0",
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
 
@@ -140,6 +156,14 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
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)
@@ -393,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
393
417
  stderr: errorMessage,
394
418
  stopReason: signal.aborted ? "aborted" : "error",
395
419
  errorMessage,
420
+ dispatchFailed: true,
396
421
  };
397
422
  }
398
423
  };
@@ -438,11 +463,20 @@ export default function (pi: ExtensionAPI): void {
438
463
  // success, a stuck one needs a human).
439
464
  monitor.removeRun(parentRunId);
440
465
  if (!sessionActive) return;
441
- const items: CompletionMessageItem[] = chain.map((r) => ({
442
- agent: r.agent,
443
- block: formatCompletionBlock(r, config.maxResultLines, ctx.cwd),
444
- triggerTurn: true,
445
- }));
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
+ });
446
480
  sendCompletionGroup(items);
447
481
  completionBatcher.flush();
448
482
  },
@@ -451,6 +485,30 @@ export default function (pi: ExtensionAPI): void {
451
485
  // in-flight chain run was already finished by its launchInLoop path).
452
486
  monitor.removeRun(parentRunId);
453
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
+ },
454
512
  );
455
513
  };
456
514
 
@@ -493,8 +551,11 @@ export default function (pi: ExtensionAPI): void {
493
551
  stderr: errorMessage,
494
552
  stopReason: backgroundSignal.aborted ? "aborted" : "error",
495
553
  errorMessage,
554
+ dispatchFailed: true,
496
555
  };
497
- 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 });
498
559
  }
499
560
 
500
561
  if (!sessionActive) return;
@@ -516,13 +577,31 @@ export default function (pi: ExtensionAPI): void {
516
577
  return;
517
578
  }
518
579
  const failed = isFailedResult(result);
519
- 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);
520
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.
521
591
  const completion: CompletionMessageItem = {
522
592
  agent: result.agent,
523
- block: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
593
+ block: modelLevel
594
+ ? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
595
+ : formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
524
596
  triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
525
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
+ }
526
605
  if (failed) {
527
606
  // Failures never wait and never hide behind a success turn: deliver
528
607
  // first so the wake-up leads with the failure; held successes follow.
@@ -533,6 +612,28 @@ export default function (pi: ExtensionAPI): void {
533
612
  }
534
613
  },
535
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
+ },
536
637
  );
537
638
 
538
639
  return pending;
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 {
@@ -158,6 +162,10 @@ export function isFailedResult(result: SingleResult): boolean {
158
162
  export function isModelLevelFailure(result: SingleResult): boolean {
159
163
  if (!isFailedResult(result)) return false;
160
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;
161
169
  // An idle timeout (stdout went silent) signals a stalled provider connection,
162
170
  // not a task-level failure: allow model fallback even if the model produced
163
171
  // partial output before going quiet.