@ferris1225/pi-subagents 0.31.0 → 1.0.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
@@ -11,18 +11,39 @@ Focused background delegation for [pi](https://pi.dev): `explore` / `worker` /
11
11
  to the main agent automatically. Install it, and the main model starts using it
12
12
  on its own — no prompt engineering, no babysitting.
13
13
 
14
+ ## 1.0 — controllable agent threads
15
+
16
+ Version 1.0 turns pi-subagents from a one-shot background runner into a small
17
+ thread runtime. Every dispatch has a stable run id and retained Pi session, so
18
+ work can be steered while it runs, parked without losing context, resumed after
19
+ settlement, retargeted, or forked into another path. An internal append-only
20
+ lifecycle trajectory keeps retries and stale generations from corrupting the
21
+ logical thread.
22
+
23
+ The common quality loop now runs end to end without waking the main agent between
24
+ steps:
25
+
26
+ ```text
27
+ reviewer (find blockers) → worker (fix) → reviewer (verify) → final PASS/FAIL
28
+ ```
29
+
30
+ Each chain is delivered as one concise completion group, while full per-run
31
+ reports remain available through `subagent_status`. Ordered model pools keep the
32
+ same retained context across provider fallback, and isolated parallel workers
33
+ use detached Git worktrees whose changes are applied back without touching the
34
+ parent index.
35
+
14
36
  ## Highlights
15
37
 
16
38
  - **Zero-setup proactive dispatch** — the extension injects a delegation directive
17
39
  into the main system prompt, so the main model automatically sends broad searches
18
40
  to `explore`, self-contained implementations to `worker`, and pre-commit reviews
19
41
  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.
42
+ - **Vision-capable image tasks** — flag screenshot/mockup/design work with
43
+ `vision: true`. The configured vision primary is followed by that agent's
44
+ backup and the current main-window model. Setup lists only in-scope,
45
+ image-capable models from providers with a configured API key or OAuth session,
46
+ and runtime failures never silently rewrite your configuration.
26
47
  - **Results come back on their own** — completions are delivered as messages that
27
48
  wake the main agent automatically, even mid-turn. No polling, no `sleep`, no
28
49
  "go check" step. `subagent_wait` is a **non-blocking** in-turn lookup by default
@@ -35,23 +56,24 @@ on its own — no prompt engineering, no babysitting.
35
56
  - **A quality gate that closes the loop** — when a reviewer returns `REVIEW_FAIL`,
36
57
  the extension dispatches a worker briefed with the concrete findings, then a
37
58
  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 })`.
59
+ Every round stays in the triggering reviewer's cwd, and chains that target the
60
+ same repository are serialized so shared-checkout edits cannot race.
61
+ - **Ordered model pools without config churn** each agent can have a primary
62
+ and backup; the current main-window model is the final candidate. Transient
63
+ provider failures retry the same candidate with backoff, while permanent stale
64
+ model/config errors and quota/auth errors advance immediately. Saved refs are
65
+ never rewritten behind your back.
66
+ - **Resumes, retargets, and forks preserve context** every run is session-backed.
67
+ `subagent_control` can steer active work, retarget it after a stable abort,
68
+ park/resume it under the same run id, or fork a parked/settled checkpoint into
69
+ a new independent run. Concurrent resume calls are serialized.
47
70
  - **Honest completions** — a run that ended with failed tool calls (e.g. a broken
48
71
  build) is reported as `completed with N failed tool call(s)` with the errors
49
72
  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).
52
- - **Live progress widget** each run's status, current activity, model, token
53
- usage, and elapsed time; auto-fix chain rounds hang under their triggering
54
- review as a tree, each finished round keeping a one-line outcome.
73
+ - **Parallel fan-out with filesystem isolation** — independent tasks run up to a
74
+ configurable limit (default 4). Parallel workers default to detached Git
75
+ worktrees; tracked, deleted, untracked, and binary changes are applied back
76
+ without touching the parent index. Failed integration keeps recovery artifacts.
55
77
  - **Recursion is structurally impossible** — children are leaf processes; the
56
78
  `subagent` tool is excluded from their toolset.
57
79
  - **Zero runtime dependencies** — agents are plain Markdown files; overriding or
@@ -121,32 +143,70 @@ subagent({
121
143
  });
122
144
  ```
123
145
 
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.
146
+ The sub-agent reads images with its `read` tool. Runtime order for a
147
+ vision-flagged run is configured `visionModel` → that agent's configured backup
148
+ current main-window model (deduplicated). A stale configured ref is attempted
149
+ once, then skipped as a permanent candidate error; it is not rewritten. A
150
+ vision-flagged auto-fix chain keeps the flag for worker/re-review rounds because
151
+ they may need to inspect the same images.
152
+
153
+ ### Controlling and stopping
130
154
 
131
- ### Waiting, inspecting, stopping
155
+ Dispatch confirmations, tool result rows, and completion blocks all show the
156
+ stable `#id`, so a thread remains directly controllable after its live UI is gone.
132
157
 
158
+ - `subagent_control` — `steer`, `retarget`, `park`, `resume`, or `fork` a logical
159
+ thread by stable run id. Resume accepts an optional replacement objective;
160
+ fork creates a new id and leaves the source unchanged. Park active work before
161
+ forking it.
133
162
  - `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.
163
+ run returns immediately; an active run tells the model to end its turn. Pass
164
+ `timeoutMs` only when you must stay in the turn.
165
+ - `subagent_status` active/parked/finished runs and full result by run id.
166
+ - `subagent_stop` — destructive cancellation. It retires that thread's retained
167
+ session (independent forks survive) and delivers exactly one aborted partial
168
+ result after the run and any worktree integration have quiesced.
169
+
170
+ Examples:
171
+
172
+ ```ts
173
+ subagent_control({ action: "steer", id: 7, instruction: "Check the Windows path too." });
174
+ subagent_control({ action: "park", id: 7 });
175
+ subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
176
+ subagent_control({ action: "fork", id: 7, objective: "Try the smaller alternative." });
177
+ ```
178
+
179
+ ### Worktree isolation
180
+
181
+ Single tasks default to `isolation: "shared"`. Parallel `worker` tasks default
182
+ to `isolation: "worktree"`; opt into shared mode only when a worker must see the
183
+ caller's live uncommitted tree. Worktree mode requires a Git repository with a
184
+ committed `HEAD` and is rejected for read-only agents.
185
+
186
+ A parked isolated thread keeps its current worktree. Resume it there; fork is
187
+ available after that isolated checkpoint settles and its seed is integrated.
188
+ Resuming or forking a settled isolated thread creates a fresh worktree, merges a
189
+ recorded checkpoint onto the current `HEAD` (including when the seed was already
190
+ committed), and clones the Pi session with the new cwd. Forks then integrate only
191
+ their unique follow-on edits, so a shared seed is applied once. A run remains
192
+ active while final Git integration is in progress and becomes `done` only after
193
+ that boundary finishes.
194
+
195
+ Every Git operation has a 120-second deadline and process-tree cleanup; captured
196
+ Git output and binary patches are capped at 64 MiB. Setup/bound failures surface
197
+ instead of hanging. Finalization failures retain the patch/worktree when
198
+ available and are recorded in `~/.pi/agent/pi-subagents-recovery.json`; later
199
+ sessions show the recovery paths again until the artifacts are removed.
141
200
 
142
201
  ## Configuration
143
202
 
144
203
  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.
204
+ set). The `/subagents-setup` wizard drives every field interactively — models, the
205
+ default plus each enabled agent's thinking level, the vision model, concurrency,
206
+ fix rounds, idle timeout, scope, and injection — with a per-agent "configure
207
+ one" menu when the config already exists. Model pickers show only models in the
208
+ current session scope that are available through a configured API key or OAuth
209
+ session. `notifyOnReviewPass` and `maxResultLines` are edited directly in the file.
150
210
 
151
211
  ```json
152
212
  {
@@ -154,6 +214,9 @@ file.
154
214
  "agentModels": {
155
215
  "explore": "anthropic/claude-haiku-4-5"
156
216
  },
217
+ "agentBackupModels": {
218
+ "explore": "openai/gpt-5-mini"
219
+ },
157
220
  "agentThinkingLevels": {
158
221
  "explore": "low"
159
222
  },
@@ -172,7 +235,8 @@ file.
172
235
  | Field | Description |
173
236
  | --- | --- |
174
237
  | `enabledAgents` | Agent names exposed to discovery and prompt injection. An empty array disables all agents. |
175
- | `agentModels` | Optional `provider/model-id` override per agent. |
238
+ | `agentModels` | Optional primary `provider/model-id` override per agent. |
239
+ | `agentBackupModels` | Optional backup per agent, tried after its primary and before the current main-window model. |
176
240
  | `agentThinkingLevels` | Optional thinking level per agent; agents without an entry use the agent's frontmatter `thinking`, then `thinkingLevel`. |
177
241
  | `thinkingLevel` | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` (default `high`). |
178
242
  | `visionModel` | Optional vision-capable model for `vision: true` tasks (screenshots/mockups/designs). Unset = falls back to the main session's current model. |
@@ -184,40 +248,41 @@ file.
184
248
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL` (default 2; `0` disables the loop). |
185
249
  | `idleTimeoutSec` | Idle watchdog: a sub-agent whose stdout goes silent for this long is terminated and retried. `0` disables it. Default 90. |
186
250
 
187
- ### Model precedence
251
+ ### Model precedence and fallback
188
252
 
189
- ```text
190
- per-agent override → current main-session model → agent frontmatter model
191
- ```
192
-
193
- For vision-flagged runs:
253
+ Normal run pool:
194
254
 
195
255
  ```text
196
- visionModel (configured) → current main-session model → agent model
256
+ configured agent primary → configured agent backup → current main-window model
197
257
  ```
198
258
 
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.
259
+ If no primary is configured, the current main-window model is primary; the
260
+ agent frontmatter model is used only when no current main model exists. For
261
+ vision runs, `visionModel` replaces the first slot while the selected agent's
262
+ backup and current main model remain the fallbacks. Duplicate refs are removed.
263
+
264
+ Each candidate gets startup-race retries. Provider failures retry the same
265
+ candidate up to five times only when transient (timeouts, network, 429, 5xx).
266
+ Permanent model/config failures (`model not found`, unknown provider, 404) and
267
+ quota/auth/billing failures skip that delay and advance immediately. No runtime
268
+ outcome rewrites `pi-subagents.json`. If the whole pool fails, the result is
269
+ handed to the main window with its retained run id and session.
205
270
 
206
- ### Resuming after a model quota/auth failure
271
+ ### Resuming retained context
207
272
 
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:
273
+ Every run stores its Pi session in a private temp directory. Same-model retries
274
+ and pool fallbacks resume that session, so searches, reads, reasoning, and edits
275
+ remain in context. A parked, completed, or failed thread can later be resumed
276
+ under its stable id:
215
277
 
216
278
  ```ts
217
- subagent({ resume: 7 }); // continue run #7 from where its model stopped
279
+ subagent_control({ action: "resume", id: 7 });
280
+ subagent_control({ action: "resume", id: 7, objective: "Continue with the repaired credentials." });
218
281
  ```
219
282
 
220
- The session is reclaimed once the resume succeeds (or when the session ends).
283
+ Use `fork` when both paths should remain available. `subagent_stop` is the
284
+ explicit destructive operation that retires a retained session; otherwise
285
+ sessions live until the parent Pi session shuts down.
221
286
 
222
287
  ### Configuration migration
223
288
 
@@ -230,7 +295,8 @@ after an update via a toast (marker persisted in `announcedFeatures`).
230
295
  ## Agent discovery and overrides
231
296
 
232
297
  - Built-in agents ship with the package; user agents live in `~/.pi/agent/agents/`;
233
- project agents in the nearest `.pi/agents/` directory.
298
+ project agents in the nearest `.pi/agents/` directory are loaded only when Pi
299
+ trusts that project.
234
300
  - For duplicate names: project overrides user overrides built-in. Keep the
235
301
  matching filename and `name` field to replace a built-in agent.
236
302
  - Optional frontmatter: `model` (default model reference), `thinking` (default
@@ -239,16 +305,18 @@ after an update via a toast (marker persisted in `announcedFeatures`).
239
305
 
240
306
  ## How it stays reliable
241
307
 
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.
308
+ - **Ordered model resilience** — transient failures retry the same candidate,
309
+ then advance through configured backup and current-main candidates. Permanent
310
+ stale-model/config and quota/auth/billing errors skip same-model backoff.
245
311
  - **Startup-race retries** — a silent zero-activity child exit (concurrent pi
246
312
  startup lock contention) is relaunched with backoff; only clean silent exits
247
313
  qualify, so real work is never duplicated.
248
314
  - **Idle watchdog** — a stalled provider stream (no output for `idleTimeoutSec`)
249
315
  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.
316
+ - **Dispatch failures surface** — partial parallel startup reports every failed
317
+ item and reason; if none start, the tool throws so Pi records a real tool error.
318
+ Dispatch crashes likewise produce a failed result instead of a silent hang.
319
+ - **Safe live status** — tool activity is credential-redacted and stripped of terminal control sequences before `subagent_status` can return it.
252
320
  - **Leaf children** — no nested delegation, no runaway trees.
253
321
 
254
322
  ## Development
@@ -259,11 +327,13 @@ npm run check
259
327
  npm test
260
328
  ```
261
329
 
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.
330
+ The source is modular: `dispatch.ts` (dispatch, controls, isolation, auto-fix),
331
+ `rpc-run.ts` / `spawn.ts` (persistent child transport + model pools),
332
+ `worktree.ts` / `session-fork.ts` (filesystem/session branching),
333
+ `trajectory.ts` (internal lifecycle history), `tools.ts`
334
+ (wait/status/control/stop), `announcements.ts` (recovery and feature notices),
335
+ and `runtime.ts` (session-scoped ownership). No runtime dependencies beyond pi peer
336
+ dependencies.
267
337
 
268
338
  ## License
269
339
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.31.0",
4
- "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
3
+ "version": "1.0.0",
4
+ "description": "Controllable background sub-agent threads for pi: explore, worker, reviewer, auto-fix chains, model pools, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "publishConfig": {
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
 
@@ -0,0 +1,59 @@
1
+ /** Session-start recovery and one-time feature announcements. */
2
+
3
+ import { stat } from "node:fs/promises";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { loadConfig, saveConfig } from "./config.ts";
6
+ import { announceRecoveryRecords } from "./recovery.ts";
7
+ import type { SubagentRuntime } from "./runtime.ts";
8
+
9
+ const ANNOUNCEMENTS: Array<{
10
+ key: string;
11
+ condition: (config: Awaited<ReturnType<typeof loadConfig>>) => boolean;
12
+ message: string;
13
+ }> = [
14
+ {
15
+ key: "visionModel",
16
+ condition: (config) => config.visionModel === undefined,
17
+ message:
18
+ "pi-subagents: new — a vision-capable model can now handle image tasks (screenshots, mockups, designs). Run /subagents-setup to configure it; until set, vision tasks use the main session's current model.",
19
+ },
20
+ ];
21
+
22
+ async function announceNewFeatures(
23
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
24
+ runtime: SubagentRuntime,
25
+ ): Promise<void> {
26
+ try {
27
+ let configExists = true;
28
+ try {
29
+ await stat(runtime.configPath);
30
+ } catch {
31
+ configExists = false;
32
+ }
33
+ if (!configExists) return;
34
+
35
+ const config = await loadConfig(runtime.configPath);
36
+ const pending = ANNOUNCEMENTS.filter(
37
+ (announcement) =>
38
+ announcement.condition(config) && !config.announcedFeatures.includes(announcement.key),
39
+ );
40
+ if (pending.length === 0) return;
41
+ await saveConfig(
42
+ {
43
+ ...config,
44
+ announcedFeatures: [...config.announcedFeatures, ...pending.map((announcement) => announcement.key)],
45
+ },
46
+ runtime.configPath,
47
+ );
48
+ for (const announcement of pending) ctx.ui.notify(announcement.message, "info");
49
+ } catch {
50
+ /* announcement failures are non-fatal */
51
+ }
52
+ }
53
+
54
+ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
55
+ pi.on("session_start", async (_event, ctx) => {
56
+ await announceRecoveryRecords(runtime.configPath, ctx);
57
+ if (ctx.mode === "tui") await announceNewFeatures(ctx, runtime);
58
+ });
59
+ }
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
  };