@ferris1225/pi-subagents 0.29.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,30 +17,44 @@ 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
29
28
  (pass `timeoutMs` to block); `subagent_status` inspects runs; `subagent_stop`
30
29
  cancels one and delivers its partial output.
30
+ - **Results are not re-narrated** — a sub-agent's completion is shown to you
31
+ verbatim, and the main agent is told not to paraphrase it back. It replies with
32
+ only its own conclusion or next step, so the same findings are never paid for
33
+ twice in tokens.
31
34
  - **A quality gate that closes the loop** — when a reviewer returns `REVIEW_FAIL`,
32
35
  the extension dispatches a worker briefed with the concrete findings, then a
33
36
  re-review, up to `maxFixRounds` times — and only then wakes the main agent.
34
- - **Self-healing model management** unavailable configured models are repaired
35
- and persisted automatically; a provider hiccup retries the same model up to 5×
36
- with backoff, then falls back once to the main window's model; terminal errors
37
- (quota/auth) short-circuit straight to the main agent; an idle watchdog kills
38
- runs that go silent; startup races are retried with backoff.
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.
39
48
  - **Honest completions** — a run that ended with failed tool calls (e.g. a broken
40
49
  build) is reported as `completed with N failed tool call(s)` with the errors
41
50
  attached — a cheerful final text can never hide a failure.
42
- - **Parallel fan-out** — independent tasks run concurrently up to a configurable
43
- 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.
44
58
  - **Live progress widget** — each run's status, current activity, model, token
45
59
  usage, and elapsed time; auto-fix chain rounds hang under their triggering
46
60
  review as a tree, each finished round keeping a one-line outcome.
@@ -113,32 +127,68 @@ subagent({
113
127
  });
114
128
  ```
115
129
 
116
- The sub-agent reads the images with its `read` tool. Model selection for
117
- vision-flagged runs: configured `visionModel` → main session's current model →
118
- agent's own model. When a configured vision model is no longer available, the
119
- TUI asks you to pick a replacement (persisted); outside the TUI it warns and
120
- falls back. A vision-flagged auto-fix chain keeps its worker/re-review rounds on
121
- 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.
122
136
 
123
- ### Waiting, inspecting, stopping
137
+ ### Controlling, inspecting, and stopping
124
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.
125
143
  - `subagent_wait` — in-turn result lookup. **Non-blocking by default**: a settled
126
- run returns its result immediately; a still-active run tells the model to end
127
- its turn (the wake-up message arrives on its own). Pass `timeoutMs` only when
128
- you must stay in the turn.
129
- - `subagent_status`what is running now, what finished this session, full
130
- result by run id.
131
- - `subagent_stop` — cancel a run (or all); the child is terminated and an aborted
132
- 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.
133
183
 
134
184
  ## Configuration
135
185
 
136
186
  Stored at `~/.pi/agent/pi-subagents.json` (follows `PI_CODING_AGENT_DIR` when
137
- set). The `/subagents-setup` wizard drives every field interactively — models,
138
- thinking levels, the vision model, concurrency, fix rounds, idle timeout, scope,
139
- and injection — with a per-agent "configure one" menu when the config already
140
- exists. `notifyOnReviewPass` and `maxResultLines` are edited directly in the
141
- 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.
142
192
 
143
193
  ```json
144
194
  {
@@ -146,6 +196,9 @@ file.
146
196
  "agentModels": {
147
197
  "explore": "anthropic/claude-haiku-4-5"
148
198
  },
199
+ "agentBackupModels": {
200
+ "explore": "openai/gpt-5-mini"
201
+ },
149
202
  "agentThinkingLevels": {
150
203
  "explore": "low"
151
204
  },
@@ -164,7 +217,8 @@ file.
164
217
  | Field | Description |
165
218
  | --- | --- |
166
219
  | `enabledAgents` | Agent names exposed to discovery and prompt injection. An empty array disables all agents. |
167
- | `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. |
168
222
  | `agentThinkingLevels` | Optional thinking level per agent; agents without an entry use the agent's frontmatter `thinking`, then `thinkingLevel`. |
169
223
  | `thinkingLevel` | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` (default `high`). |
170
224
  | `visionModel` | Optional vision-capable model for `vision: true` tasks (screenshots/mockups/designs). Unset = falls back to the main session's current model. |
@@ -176,24 +230,41 @@ file.
176
230
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL` (default 2; `0` disables the loop). |
177
231
  | `idleTimeoutSec` | Idle watchdog: a sub-agent whose stdout goes silent for this long is terminated and retried. `0` disables it. Default 90. |
178
232
 
179
- ### Model precedence
233
+ ### Model precedence and fallback
234
+
235
+ Normal run pool:
180
236
 
181
237
  ```text
182
- per-agent overridecurrent main-session modelagent frontmatter model
238
+ configured agent primaryconfigured agent backupcurrent main-window model
183
239
  ```
184
240
 
185
- For vision-flagged runs:
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.
186
245
 
187
- ```text
188
- visionModel (configured) current main-session model agent model
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.
252
+
253
+ ### Resuming retained context
254
+
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:
259
+
260
+ ```ts
261
+ subagent_control({ action: "resume", id: 7 });
262
+ subagent_control({ action: "resume", id: 7, objective: "Continue with the repaired credentials." });
189
263
  ```
190
264
 
191
- Unavailable configured models are replaced with a usable current-session model
192
- and the repaired config is saved. At runtime, a model that fails at the provider
193
- level before producing output is retried (same model up to 5× on transient
194
- errors, then once with the main window's model — per-run only, never persisted);
195
- if everything fails, the task is handed back to the main window with
196
- instructions to execute it directly.
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.
197
268
 
198
269
  ### Configuration migration
199
270
 
@@ -206,7 +277,8 @@ after an update via a toast (marker persisted in `announcedFeatures`).
206
277
  ## Agent discovery and overrides
207
278
 
208
279
  - Built-in agents ship with the package; user agents live in `~/.pi/agent/agents/`;
209
- 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.
210
282
  - For duplicate names: project overrides user overrides built-in. Keep the
211
283
  matching filename and `name` field to replace a built-in agent.
212
284
  - Optional frontmatter: `model` (default model reference), `thinking` (default
@@ -215,16 +287,17 @@ after an update via a toast (marker persisted in `announcedFeatures`).
215
287
 
216
288
  ## How it stays reliable
217
289
 
218
- - **Three-layer model resilience** — same-model retry with backoff on transient
219
- provider errors (503/429/timeout/network), then a one-shot fallback to the main
220
- 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.
221
293
  - **Startup-race retries** — a silent zero-activity child exit (concurrent pi
222
294
  startup lock contention) is relaunched with backoff; only clean silent exits
223
295
  qualify, so real work is never duplicated.
224
296
  - **Idle watchdog** — a stalled provider stream (no output for `idleTimeoutSec`)
225
297
  terminates the child and retries via the normal fallback path.
226
- - **Dispatch crashes surface** — an exception in the dispatch layer produces a
227
- 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.
228
301
  - **Leaf children** — no nested delegation, no runaway trees.
229
302
 
230
303
  ## Development
@@ -235,30 +308,13 @@ npm run check
235
308
  npm test
236
309
  ```
237
310
 
238
- The source is modular: `dispatch.ts` (subagent tool + auto-fix chain + vision
239
- model), `tools.ts` (wait/status/stop), `widget.ts` (widget + announcements),
240
- `runtime.ts` (shared session state), `spawn.ts` (child process layer),
241
- `monitor.ts` (run tracking), `setup.ts` (wizard), `prompt.ts` (delegation
242
- directive). No runtime dependencies beyond pi peer dependencies.
243
-
244
- ## Acknowledgments
245
-
246
- - The official [pi subagent example](https://github.com/earendil-works/pi)
247
- (`examples/extensions/subagent`) — the child-process dispatch and
248
- event-stream handling build on it.
249
- - [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) — the
250
- live widget and parallel fan-out follow its design.
251
- - [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) — the
252
- result-delivery design is learned from it: prompt **steer** delivery, a
253
- non-blocking `subagent_wait`, status inspection, and stop/interrupt
254
- management. Its status-file and workflow-script orchestration are deliberately
255
- out of scope: this extension stays a focused 3-agent delegation tool with a
256
- configuration wizard.
257
- - The sub-agent pattern itself, popularized by
258
- [Claude Code](https://github.com/anthropics/claude-code).
259
-
260
- The agent prompts and extension code are written independently for this
261
- project; the projects above served as design references.
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.
262
318
 
263
319
  ## License
264
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.29.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/completion.ts CHANGED
@@ -151,3 +151,31 @@ export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass:
151
151
  reviewVerdict(getResultOutput(result)) === "pass"
152
152
  );
153
153
  }
154
+
155
+ /** Minimal shape of an active run, for the "others still running" footer. Kept
156
+ * decoupled from the monitor's RunView so this stays a pure, easily tested
157
+ * formatter; the caller maps its live runs into this shape. */
158
+ export interface ActiveRunFoot {
159
+ id: number;
160
+ agent: string;
161
+ /** Optional content label (task-derived) shown next to the agent name. */
162
+ label?: string;
163
+ }
164
+
165
+ /**
166
+ * Footer appended to a completion message when OTHER runs are still active, so
167
+ * the main agent does not declare the overall task done prematurely. A result
168
+ * arriving for one run does not mean sibling runs are finished; naming them
169
+ * gives the main agent concrete, in-context awareness to keep waiting.
170
+ *
171
+ * Returns "" when nothing is active (the common, single-run case stays quiet).
172
+ */
173
+ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string {
174
+ if (runs.length === 0) return "";
175
+ const listed = runs.slice(0, maxListed);
176
+ const items = listed
177
+ .map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
178
+ .join(", ");
179
+ const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
180
+ return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
181
+ }
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
  };