@ferris1225/pi-subagents 0.31.0 → 0.32.2

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
@@ -17,12 +17,11 @@ on its own — no prompt engineering, no babysitting.
17
17
  into the main system prompt, so the main model automatically sends broad searches
18
18
  to `explore`, self-contained implementations to `worker`, and pre-commit reviews
19
19
  to `reviewer`. You just use pi; delegation happens by itself.
20
- - **Vision-capable image tasks** — a task that may need to view screenshots,
21
- mockups, or design files is flagged `vision: true`; the sub-agent then runs on
22
- the vision model you configure in `/subagents-setup`. Not configured? It falls
23
- back to the main session's current model. Configured model unavailable? You are
24
- asked to pick a replacement, which is persisted. The agents know they can `read`
25
- image files when the brief asks.
20
+ - **Vision-capable image tasks** — flag screenshot/mockup/design work with
21
+ `vision: true`. The configured vision primary is followed by that agent's
22
+ backup and the current main-window model. The setup picker offers only
23
+ image-capable models (while clearly marking an incompatible stale saved ref),
24
+ and runtime failures never silently rewrite your configuration.
26
25
  - **Results come back on their own** — completions are delivered as messages that
27
26
  wake the main agent automatically, even mid-turn. No polling, no `sleep`, no
28
27
  "go check" step. `subagent_wait` is a **non-blocking** in-turn lookup by default
@@ -35,20 +34,27 @@ on its own — no prompt engineering, no babysitting.
35
34
  - **A quality gate that closes the loop** — when a reviewer returns `REVIEW_FAIL`,
36
35
  the extension dispatches a worker briefed with the concrete findings, then a
37
36
  re-review, up to `maxFixRounds` times — and only then wakes the main agent.
38
- - **Self-healing model management** unavailable configured models are repaired
39
- and persisted automatically; a provider hiccup retries the same model up to 5×
40
- with backoff, then falls back once to the main window's model; terminal errors
41
- (quota/auth) short-circuit straight to the main agent; an idle watchdog kills
42
- runs that go silent; startup races are retried with backoff.
43
- - **Resumes, not restarts, on a model switch** every run is session-backed, so
44
- a model quota/auth failure resumes on another model with its earlier searches,
45
- reads, and edits intact (no re-scanning). When every model is out, the run is
46
- handed back with its session preserved for a one-call `subagent({ resume })`.
37
+ Every round stays in the triggering reviewer's cwd, and chains that target the
38
+ same repository are serialized so shared-checkout edits cannot race.
39
+ - **Ordered model pools without config churn** each agent can have a primary
40
+ and backup; the current main-window model is the final candidate. Transient
41
+ provider failures retry the same candidate with backoff, while permanent stale
42
+ model/config errors and quota/auth errors advance immediately. Saved refs are
43
+ never rewritten behind your back.
44
+ - **Resumes, retargets, and forks preserve context** every run is session-backed.
45
+ `subagent_control` can steer active work, retarget it after a stable abort,
46
+ park/resume it under the same run id, or fork a parked/settled checkpoint into
47
+ a new independent run. Concurrent resume calls are serialized.
47
48
  - **Honest completions** — a run that ended with failed tool calls (e.g. a broken
48
49
  build) is reported as `completed with N failed tool call(s)` with the errors
49
50
  attached — a cheerful final text can never hide a failure.
50
- - **Parallel fan-out** — independent tasks run concurrently up to a configurable
51
- limit (default 4).
51
+ - **Parallel fan-out with filesystem isolation** — independent tasks run up to a
52
+ configurable limit (default 4). Parallel workers default to detached Git
53
+ worktrees; tracked, deleted, untracked, and binary changes are applied back
54
+ without touching the parent index. Failed integration keeps recovery artifacts.
55
+ - **Live inspector** — `/subagents-inspect` opens a master/detail
56
+ overlay with thread state, model chain, usage, transcript, recent tools,
57
+ worktree/fork relations, and the append-only control trajectory.
52
58
  - **Live progress widget** — each run's status, current activity, model, token
53
59
  usage, and elapsed time; auto-fix chain rounds hang under their triggering
54
60
  review as a tree, each finished round keeping a one-line outcome.
@@ -121,32 +127,68 @@ subagent({
121
127
  });
122
128
  ```
123
129
 
124
- The sub-agent reads the images with its `read` tool. Model selection for
125
- vision-flagged runs: configured `visionModel` → main session's current model →
126
- agent's own model. When a configured vision model is no longer available, the
127
- TUI asks you to pick a replacement (persisted); outside the TUI it warns and
128
- falls back. A vision-flagged auto-fix chain keeps its worker/re-review rounds on
129
- the vision model too, since they re-read the same images.
130
+ The sub-agent reads images with its `read` tool. Runtime order for a
131
+ vision-flagged run is configured `visionModel` → that agent's configured backup
132
+ current main-window model (deduplicated). A stale configured ref is attempted
133
+ once, then skipped as a permanent candidate error; it is not rewritten. A
134
+ vision-flagged auto-fix chain keeps the flag for worker/re-review rounds because
135
+ they may need to inspect the same images.
130
136
 
131
- ### Waiting, inspecting, stopping
137
+ ### Controlling, inspecting, and stopping
132
138
 
139
+ - `subagent_control` — `steer`, `retarget`, `park`, `resume`, or `fork` a logical
140
+ thread by stable run id. Resume accepts an optional replacement objective;
141
+ fork creates a new id and leaves the source unchanged. Park active work before
142
+ forking it.
133
143
  - `subagent_wait` — in-turn result lookup. **Non-blocking by default**: a settled
134
- run returns its result immediately; a still-active run tells the model to end
135
- its turn (the wake-up message arrives on its own). Pass `timeoutMs` only when
136
- you must stay in the turn.
137
- - `subagent_status`what is running now, what finished this session, full
138
- result by run id.
139
- - `subagent_stop` — cancel a run (or all); the child is terminated and an aborted
140
- result with partial output is delivered.
144
+ run returns immediately; an active run tells the model to end its turn. Pass
145
+ `timeoutMs` only when you must stay in the turn.
146
+ - `subagent_status` active/parked/finished runs and full result by run id.
147
+ - `/subagents-inspect` — interactive live thread/transcript/tool/trajectory
148
+ overlay; press `p` to park or resume the selected thread.
149
+ - `subagent_stop` — destructive cancellation. It retires that thread's retained
150
+ session (independent forks survive) and delivers exactly one aborted partial
151
+ result after the run and any worktree integration have quiesced.
152
+
153
+ Examples:
154
+
155
+ ```ts
156
+ subagent_control({ action: "steer", id: 7, instruction: "Check the Windows path too." });
157
+ subagent_control({ action: "park", id: 7 });
158
+ subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
159
+ subagent_control({ action: "fork", id: 7, objective: "Try the smaller alternative." });
160
+ ```
161
+
162
+ ### Worktree isolation
163
+
164
+ Single tasks default to `isolation: "shared"`. Parallel `worker` tasks default
165
+ to `isolation: "worktree"`; opt into shared mode only when a worker must see the
166
+ caller's live uncommitted tree. Worktree mode requires a Git repository with a
167
+ committed `HEAD` and is rejected for read-only agents.
168
+
169
+ A parked isolated thread keeps its current worktree. Resume it there; fork is
170
+ available after that isolated checkpoint settles and its seed is integrated.
171
+ Resuming or forking a settled isolated thread creates a fresh worktree, merges a
172
+ recorded checkpoint onto the current `HEAD` (including when the seed was already
173
+ committed), and clones the Pi session with the new cwd. Forks then integrate only
174
+ their unique follow-on edits, so a shared seed is applied once. A run remains
175
+ active while final Git integration is in progress and becomes `done` only after
176
+ that boundary finishes.
177
+
178
+ Every Git operation has a 120-second deadline and process-tree cleanup; captured
179
+ Git output and binary patches are capped at 64 MiB. Setup/bound failures surface
180
+ instead of hanging. Finalization failures retain the patch/worktree when
181
+ available and are recorded in `~/.pi/agent/pi-subagents-recovery.json`; later
182
+ sessions show the recovery paths again until the artifacts are removed.
141
183
 
142
184
  ## Configuration
143
185
 
144
186
  Stored at `~/.pi/agent/pi-subagents.json` (follows `PI_CODING_AGENT_DIR` when
145
- set). The `/subagents-setup` wizard drives every field interactively — models,
146
- thinking levels, the vision model, concurrency, fix rounds, idle timeout, scope,
147
- and injection — with a per-agent "configure one" menu when the config already
148
- exists. `notifyOnReviewPass` and `maxResultLines` are edited directly in the
149
- file.
187
+ set). The `/subagents-setup` wizard drives every field interactively — models, the
188
+ default plus each enabled agent's thinking level, the vision model, concurrency,
189
+ fix rounds, idle timeout, scope, and injection — with a per-agent "configure
190
+ one" menu when the config already exists. `notifyOnReviewPass` and
191
+ `maxResultLines` are edited directly in the file.
150
192
 
151
193
  ```json
152
194
  {
@@ -154,6 +196,9 @@ file.
154
196
  "agentModels": {
155
197
  "explore": "anthropic/claude-haiku-4-5"
156
198
  },
199
+ "agentBackupModels": {
200
+ "explore": "openai/gpt-5-mini"
201
+ },
157
202
  "agentThinkingLevels": {
158
203
  "explore": "low"
159
204
  },
@@ -172,7 +217,8 @@ file.
172
217
  | Field | Description |
173
218
  | --- | --- |
174
219
  | `enabledAgents` | Agent names exposed to discovery and prompt injection. An empty array disables all agents. |
175
- | `agentModels` | Optional `provider/model-id` override per agent. |
220
+ | `agentModels` | Optional primary `provider/model-id` override per agent. |
221
+ | `agentBackupModels` | Optional backup per agent, tried after its primary and before the current main-window model. |
176
222
  | `agentThinkingLevels` | Optional thinking level per agent; agents without an entry use the agent's frontmatter `thinking`, then `thinkingLevel`. |
177
223
  | `thinkingLevel` | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` (default `high`). |
178
224
  | `visionModel` | Optional vision-capable model for `vision: true` tasks (screenshots/mockups/designs). Unset = falls back to the main session's current model. |
@@ -184,40 +230,41 @@ file.
184
230
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL` (default 2; `0` disables the loop). |
185
231
  | `idleTimeoutSec` | Idle watchdog: a sub-agent whose stdout goes silent for this long is terminated and retried. `0` disables it. Default 90. |
186
232
 
187
- ### Model precedence
188
-
189
- ```text
190
- per-agent override → current main-session model → agent frontmatter model
191
- ```
233
+ ### Model precedence and fallback
192
234
 
193
- For vision-flagged runs:
235
+ Normal run pool:
194
236
 
195
237
  ```text
196
- visionModel (configured) → current main-session model → agent model
238
+ configured agent primary → configured agent backup → current main-window model
197
239
  ```
198
240
 
199
- Unavailable configured models are replaced with a usable current-session model
200
- and the repaired config is saved. At runtime, a model that fails at the provider
201
- level before producing output is retried (same model up to 5× on transient
202
- errors, then once with the main window's model per-run only, never persisted);
203
- if everything fails, the task is handed back to the main window with
204
- instructions to execute it directly.
241
+ If no primary is configured, the current main-window model is primary; the
242
+ agent frontmatter model is used only when no current main model exists. For
243
+ vision runs, `visionModel` replaces the first slot while the selected agent's
244
+ backup and current main model remain the fallbacks. Duplicate refs are removed.
245
+
246
+ Each candidate gets startup-race retries. Provider failures retry the same
247
+ candidate up to five times only when transient (timeouts, network, 429, 5xx).
248
+ Permanent model/config failures (`model not found`, unknown provider, 404) and
249
+ quota/auth/billing failures skip that delay and advance immediately. No runtime
250
+ outcome rewrites `pi-subagents.json`. If the whole pool fails, the result is
251
+ handed to the main window with its retained run id and session.
205
252
 
206
- ### Resuming after a model quota/auth failure
253
+ ### Resuming retained context
207
254
 
208
- Every sub-agent run is **session-backed**: its pi session is persisted to a
209
- temp dir for the run. When a model fails at the provider level, the retry and
210
- the fallback **resume that session** instead of starting over so a model
211
- switch inherits the sub-agent's earlier searches, reads, and edits and never
212
- re-scans. If every available model is exhausted (e.g. the account is out of
213
- quota), the run is handed back with its session preserved; once you have a
214
- working model again, resume it in-context:
255
+ Every run stores its Pi session in a private temp directory. Same-model retries
256
+ and pool fallbacks resume that session, so searches, reads, reasoning, and edits
257
+ remain in context. A parked, completed, or failed thread can later be resumed
258
+ under its stable id:
215
259
 
216
260
  ```ts
217
- subagent({ resume: 7 }); // continue run #7 from where its model stopped
261
+ subagent_control({ action: "resume", id: 7 });
262
+ subagent_control({ action: "resume", id: 7, objective: "Continue with the repaired credentials." });
218
263
  ```
219
264
 
220
- The session is reclaimed once the resume succeeds (or when the session ends).
265
+ Use `fork` when both paths should remain available. `subagent_stop` is the
266
+ explicit destructive operation that retires a retained session; otherwise
267
+ sessions live until the parent Pi session shuts down.
221
268
 
222
269
  ### Configuration migration
223
270
 
@@ -230,7 +277,8 @@ after an update via a toast (marker persisted in `announcedFeatures`).
230
277
  ## Agent discovery and overrides
231
278
 
232
279
  - Built-in agents ship with the package; user agents live in `~/.pi/agent/agents/`;
233
- project agents in the nearest `.pi/agents/` directory.
280
+ project agents in the nearest `.pi/agents/` directory are loaded only when Pi
281
+ trusts that project.
234
282
  - For duplicate names: project overrides user overrides built-in. Keep the
235
283
  matching filename and `name` field to replace a built-in agent.
236
284
  - Optional frontmatter: `model` (default model reference), `thinking` (default
@@ -239,16 +287,17 @@ after an update via a toast (marker persisted in `announcedFeatures`).
239
287
 
240
288
  ## How it stays reliable
241
289
 
242
- - **Three-layer model resilience** — same-model retry with backoff on transient
243
- provider errors (503/429/timeout/network), then a one-shot fallback to the main
244
- window's model. Terminal errors (quota/billing/invalid key/auth) never retry.
290
+ - **Ordered model resilience** — transient failures retry the same candidate,
291
+ then advance through configured backup and current-main candidates. Permanent
292
+ stale-model/config and quota/auth/billing errors skip same-model backoff.
245
293
  - **Startup-race retries** — a silent zero-activity child exit (concurrent pi
246
294
  startup lock contention) is relaunched with backoff; only clean silent exits
247
295
  qualify, so real work is never duplicated.
248
296
  - **Idle watchdog** — a stalled provider stream (no output for `idleTimeoutSec`)
249
297
  terminates the child and retries via the normal fallback path.
250
- - **Dispatch crashes surface** — an exception in the dispatch layer produces a
251
- failed result with a notification, never a silent hang.
298
+ - **Dispatch failures surface** — partial parallel startup reports every failed
299
+ item and reason; if none start, the tool throws so Pi records a real tool error.
300
+ Dispatch crashes likewise produce a failed result instead of a silent hang.
252
301
  - **Leaf children** — no nested delegation, no runaway trees.
253
302
 
254
303
  ## Development
@@ -259,11 +308,13 @@ npm run check
259
308
  npm test
260
309
  ```
261
310
 
262
- The source is modular: `dispatch.ts` (subagent tool + auto-fix chain + vision
263
- model), `tools.ts` (wait/status/stop), `widget.ts` (widget + announcements),
264
- `runtime.ts` (shared session state), `spawn.ts` (child process layer),
265
- `monitor.ts` (run tracking), `setup.ts` (wizard), `prompt.ts` (delegation
266
- directive). No runtime dependencies beyond pi peer dependencies.
311
+ The source is modular: `dispatch.ts` (dispatch, controls, isolation, auto-fix),
312
+ `rpc-run.ts` / `spawn.ts` (persistent child transport + model pools),
313
+ `worktree.ts` / `session-fork.ts` (filesystem/session branching),
314
+ `trajectory.ts` / `inspector*.ts` (safe live inspection), `tools.ts`
315
+ (wait/status/control/stop), `widget.ts` (widget + recovery announcements), and
316
+ `runtime.ts` (session-scoped ownership). No runtime dependencies beyond pi peer
317
+ dependencies.
267
318
 
268
319
  ## License
269
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.31.0",
3
+ "version": "0.32.2",
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/agents.ts CHANGED
@@ -123,16 +123,16 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
123
123
  export interface DiscoverOptions {
124
124
  /** Which directories to read from. Default: "user". */
125
125
  scope?: AgentScope;
126
- /** If provided and non-empty, only agents whose name is listed are returned. */
126
+ /** If provided, only agents whose name is listed are returned. */
127
127
  enabledNames?: readonly string[];
128
- /** Per-agent model override ("provider/model-id"), keyed by agent name. */
129
- modelOverrides?: Record<string, string>;
128
+ /** Project-controlled prompts are loaded only after Pi trusts the project. */
129
+ projectTrusted?: boolean;
130
130
  /** Override the built-in agents directory (used by tests). */
131
131
  builtinDir?: string;
132
132
  }
133
133
 
134
134
  /**
135
- * Discover agents across scopes, apply enable-filter and model overrides.
135
+ * Discover agents across scopes and apply the enabled-name filter.
136
136
  * Override priority for the same name: project > user > builtin.
137
137
  */
138
138
  export function discoverAgents(cwd: string, options: DiscoverOptions = {}): AgentDiscoveryResult {
@@ -143,7 +143,9 @@ export function discoverAgents(cwd: string, options: DiscoverOptions = {}): Agen
143
143
  const builtin = loadAgentsFromDir(builtinDir, "builtin");
144
144
  const user = scope === "project" ? [] : loadAgentsFromDir(join(getAgentDir(), "agents"), "user");
145
145
  const project =
146
- scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
146
+ scope === "user" || !projectAgentsDir || options.projectTrusted !== true
147
+ ? []
148
+ : loadAgentsFromDir(projectAgentsDir, "project");
147
149
 
148
150
  // Merge with override priority builtin < user < project.
149
151
  const byName = new Map<string, AgentConfig>();
@@ -153,18 +155,11 @@ export function discoverAgents(cwd: string, options: DiscoverOptions = {}): Agen
153
155
 
154
156
  let agents = Array.from(byName.values());
155
157
 
156
- if (options.enabledNames && options.enabledNames.length > 0) {
158
+ if (options.enabledNames !== undefined) {
157
159
  const enabled = new Set(options.enabledNames);
158
160
  agents = agents.filter((agent) => enabled.has(agent.name));
159
161
  }
160
162
 
161
- if (options.modelOverrides) {
162
- agents = agents.map((agent) => {
163
- const override = options.modelOverrides?.[agent.name];
164
- return override ? { ...agent, model: override } : agent;
165
- });
166
- }
167
-
168
163
  return { agents, projectAgentsDir };
169
164
  }
170
165
 
package/src/background.ts CHANGED
@@ -14,6 +14,7 @@ export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
14
14
  interface PendingTask {
15
15
  task: BackgroundTask;
16
16
  controller: AbortController;
17
+ complete: () => void;
17
18
  /** Called when a queued task is aborted before its body ever runs (drain skips
18
19
  * an already-aborted entry; cancelAll aborts every pending entry), so the
19
20
  * task body never produces a result. Callers that resolve waiters on a run id
@@ -23,13 +24,15 @@ interface PendingTask {
23
24
  onCancelled?: () => void;
24
25
  /** Invoked when the task throws and was not cancelled (cancellation is not a
25
26
  * failure — e.g. session shutdown races must never be reported as errors). */
26
- onError?: (error: unknown) => void;
27
+ onError?: (error: unknown) => void | Promise<void>;
27
28
  }
28
29
 
29
30
  export class BackgroundTaskQueue {
30
31
  private concurrency: number;
31
32
  private readonly pending: PendingTask[] = [];
32
33
  private readonly active = new Set<AbortController>();
34
+ private readonly completions = new WeakMap<AbortController, Promise<void>>();
35
+ private readonly idleWaiters = new Set<() => void>();
33
36
  private stopped = false;
34
37
 
35
38
  constructor(concurrency: number) {
@@ -46,19 +49,55 @@ export class BackgroundTaskQueue {
46
49
  this.drain();
47
50
  }
48
51
 
49
- enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void): AbortController {
52
+ enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
50
53
  const controller = new AbortController();
54
+ let complete!: () => void;
55
+ const completion = new Promise<void>((resolve) => {
56
+ complete = resolve;
57
+ });
58
+ this.completions.set(controller, completion);
51
59
  if (this.stopped) {
52
60
  controller.abort();
53
61
  this.runCancelled(onCancelled);
62
+ complete();
54
63
  return controller;
55
64
  }
56
65
 
57
- this.pending.push({ task, controller, onCancelled, onError });
66
+ this.pending.push({ task, controller, complete, onCancelled, onError });
58
67
  this.drain();
59
68
  return controller;
60
69
  }
61
70
 
71
+ /** Resolve after this exact task has left the pending/active sets. This is
72
+ * stronger than waiting for its task body: callers may safely reuse a
73
+ * concurrency slot or the task's persisted checkpoint after it resolves. */
74
+ waitForTask(controller: AbortController | undefined): Promise<void> {
75
+ if (!controller) return Promise.resolve();
76
+ return this.completions.get(controller) ?? Promise.resolve();
77
+ }
78
+
79
+ /** Cancel one queued/running task. Queued entries are removed immediately;
80
+ * active entries resolve waitForTask only after their body and error handler
81
+ * have quiesced and the concurrency slot has been released. */
82
+ cancel(controller: AbortController | undefined): void {
83
+ if (!controller) return;
84
+ controller.abort();
85
+ const index = this.pending.findIndex((entry) => entry.controller === controller);
86
+ if (index !== -1) {
87
+ const [entry] = this.pending.splice(index, 1);
88
+ this.runCancelled(entry.onCancelled);
89
+ entry.complete();
90
+ this.drain();
91
+ this.resolveIdleWaiters();
92
+ }
93
+ }
94
+
95
+ /** Resolve once no queued or running task remains. */
96
+ waitForIdle(): Promise<void> {
97
+ if (this.pending.length === 0 && this.active.size === 0) return Promise.resolve();
98
+ return new Promise<void>((resolve) => this.idleWaiters.add(resolve));
99
+ }
100
+
62
101
  /** Stop queued work and request cancellation for running work. */
63
102
  cancelAll(): void {
64
103
  if (this.stopped) return;
@@ -67,8 +106,10 @@ export class BackgroundTaskQueue {
67
106
  for (const entry of this.pending.splice(0)) {
68
107
  entry.controller.abort();
69
108
  this.runCancelled(entry.onCancelled);
109
+ entry.complete();
70
110
  }
71
111
  for (const controller of this.active) controller.abort();
112
+ this.resolveIdleWaiters();
72
113
  }
73
114
 
74
115
  /** Cancellation callbacks are user-supplied: a throw must never break the queue
@@ -85,28 +126,40 @@ export class BackgroundTaskQueue {
85
126
  private drain(): void {
86
127
  while (!this.stopped && this.active.size < this.concurrency) {
87
128
  const entry = this.pending.shift();
88
- if (!entry) return;
129
+ if (!entry) {
130
+ this.resolveIdleWaiters();
131
+ return;
132
+ }
89
133
  if (entry.controller.signal.aborted) {
90
134
  this.runCancelled(entry.onCancelled);
135
+ entry.complete();
91
136
  continue;
92
137
  }
93
138
 
94
139
  this.active.add(entry.controller);
95
140
  void entry.task(entry.controller.signal)
96
- .catch((error: unknown) => {
141
+ .catch(async (error: unknown) => {
97
142
  // Cancellation is not a failure: aborted work (e.g. session
98
143
  // shutdown) must never be reported as an exception.
99
144
  if (entry.controller.signal.aborted) return;
100
145
  try {
101
- entry.onError?.(error);
146
+ await entry.onError?.(error);
102
147
  } catch {
103
148
  /* error reporting must never break the queue */
104
149
  }
105
150
  })
106
151
  .finally(() => {
107
152
  this.active.delete(entry.controller);
153
+ entry.complete();
108
154
  this.drain();
155
+ this.resolveIdleWaiters();
109
156
  });
110
157
  }
111
158
  }
159
+
160
+ private resolveIdleWaiters(): void {
161
+ if (this.pending.length > 0 || this.active.size > 0) return;
162
+ for (const resolve of this.idleWaiters) resolve();
163
+ this.idleWaiters.clear();
164
+ }
112
165
  }
package/src/config.ts CHANGED
@@ -25,7 +25,7 @@ export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explore", "worker", "
25
25
 
26
26
  /**
27
27
  * Agents that used to ship but were removed. normalizeConfig strips them from
28
- * enabledAgents/agentModels so upgraded installs clean their config automatically
28
+ * enabledAgents/model mappings so upgraded installs clean their config automatically
29
29
  * (the schema-upgrade save in loadConfig then persists the cleanup).
30
30
  */
31
31
  export const REMOVED_AGENT_NAMES: readonly string[] = ["plan"];
@@ -70,8 +70,10 @@ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
70
70
  export interface SubagentsConfig {
71
71
  /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
72
72
  enabledAgents: string[];
73
- /** Per-agent model override, keyed by agent name, as "provider/model-id". */
73
+ /** Per-agent primary model override, keyed by agent name, as "provider/model-id". */
74
74
  agentModels: Record<string, string>;
75
+ /** Optional per-agent backup model, tried after the primary and before the current main-window model. */
76
+ agentBackupModels: Record<string, string>;
75
77
  /** Per-agent thinking-level override, keyed by agent name. */
76
78
  agentThinkingLevels: Record<string, ThinkingLevel>;
77
79
  /** Thinking level for sub-agents without a per-agent override or frontmatter default. Default: "high". */
@@ -125,6 +127,7 @@ export interface SubagentsConfig {
125
127
  export const DEFAULT_CONFIG: SubagentsConfig = {
126
128
  enabledAgents: [...DEFAULT_ENABLED_AGENTS],
127
129
  agentModels: {},
130
+ agentBackupModels: {},
128
131
  agentThinkingLevels: {},
129
132
  thinkingLevel: DEFAULT_THINKING_LEVEL,
130
133
  notifyOnReviewPass: false,
@@ -172,6 +175,7 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
172
175
  const config: SubagentsConfig = {
173
176
  enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
174
177
  agentModels: {},
178
+ agentBackupModels: {},
175
179
  agentThinkingLevels: {},
176
180
  thinkingLevel: DEFAULT_CONFIG.thinkingLevel,
177
181
  notifyOnReviewPass: DEFAULT_CONFIG.notifyOnReviewPass,
@@ -202,6 +206,13 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
202
206
  }
203
207
  }
204
208
 
209
+ if (isRecord(raw.agentBackupModels)) {
210
+ for (const [key, value] of Object.entries(raw.agentBackupModels)) {
211
+ if (REMOVED_AGENT_NAMES.includes(key.trim())) continue;
212
+ if (isModelReference(value)) config.agentBackupModels[key.trim()] = value.trim();
213
+ }
214
+ }
215
+
205
216
  if (isRecord(raw.agentThinkingLevels)) {
206
217
  for (const [key, value] of Object.entries(raw.agentThinkingLevels)) {
207
218
  if (REMOVED_AGENT_NAMES.includes(key.trim())) continue;
@@ -272,6 +283,7 @@ function defaultConfig(): SubagentsConfig {
272
283
  ...DEFAULT_CONFIG,
273
284
  enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
274
285
  agentModels: {},
286
+ agentBackupModels: {},
275
287
  agentThinkingLevels: {},
276
288
  announcedFeatures: [],
277
289
  };