@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 +144 -74
- package/package.json +2 -2
- package/src/agents.ts +8 -13
- package/src/announcements.ts +59 -0
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1833 -845
- package/src/fixloop.ts +1 -1
- package/src/format.ts +30 -4
- package/src/index.ts +9 -10
- package/src/models.ts +184 -54
- package/src/monitor.ts +141 -93
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +991 -0
- package/src/runtime.ts +284 -145
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +557 -977
- package/src/tools.ts +730 -409
- package/src/trajectory.ts +312 -0
- package/src/ui.ts +32 -16
- package/src/worktree.ts +687 -0
- package/src/widget.ts +0 -182
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** —
|
|
21
|
-
|
|
22
|
-
the
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
limit (default 4).
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
125
|
-
vision-flagged
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
- `
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
147
|
-
and injection — with a per-agent "configure
|
|
148
|
-
exists.
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
+
configured agent primary → configured agent backup → current main-window model
|
|
197
257
|
```
|
|
198
258
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
271
|
+
### Resuming retained context
|
|
207
272
|
|
|
208
|
-
Every
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
251
|
-
|
|
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` (
|
|
263
|
-
|
|
264
|
-
`
|
|
265
|
-
`
|
|
266
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
|
126
|
+
/** If provided, only agents whose name is listed are returned. */
|
|
127
127
|
enabledNames?: readonly string[];
|
|
128
|
-
/**
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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/
|
|
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
|
};
|