@ferris1225/pi-subagents 4.2.8 → 4.2.12
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/LICENSE +23 -23
- package/README.md +26 -4
- package/agents/executor.md +4 -3
- package/agents/explorer.md +37 -37
- package/package.json +9 -1
- package/src/background.ts +205 -205
- package/src/completion.ts +165 -165
- package/src/config.ts +308 -308
- package/src/dispatch.ts +10 -4
- package/src/format.ts +8 -6
- package/src/models.ts +203 -203
- package/src/monitor.ts +3 -2
- package/src/prompt.ts +1 -0
- package/src/recovery.ts +163 -163
- package/src/session-fork.ts +86 -86
- package/src/setup.ts +341 -341
- package/src/spawn.ts +663 -658
- package/src/status.ts +4 -3
- package/src/temp-hygiene.ts +230 -230
- package/src/tools.ts +1 -1
- package/src/ui.ts +248 -248
package/LICENSE
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 ferris1225
|
|
4
|
-
Portions of agents/cleaner.md adapted from reclaim-code-entropy:
|
|
5
|
-
Copyright (c) 2026 Yevanchen
|
|
6
|
-
|
|
7
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
-
in the Software without restriction, including without limitation the rights
|
|
10
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
-
furnished to do so, subject to the following conditions:
|
|
13
|
-
|
|
14
|
-
The above copyright notice and this permission notice shall be included in all
|
|
15
|
-
copies or substantial portions of the Software.
|
|
16
|
-
|
|
17
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ferris1225
|
|
4
|
+
Portions of agents/cleaner.md adapted from reclaim-code-entropy:
|
|
5
|
+
Copyright (c) 2026 Yevanchen
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ Every child is an isolated leaf pi process with its own context window and no
|
|
|
62
62
|
memory of your conversation, so the brief is its only input. A good brief carries
|
|
63
63
|
the goal, exact paths, constraints, and expected output — which is what the
|
|
64
64
|
injected delegation guidance produces when the main agent dispatches for you.
|
|
65
|
+
A named defect is not yet a change: confirm it on current code before fixing
|
|
66
|
+
or briefing a fix. The executor re-reads before it edits.
|
|
65
67
|
|
|
66
68
|
```text
|
|
67
69
|
You
|
|
@@ -207,13 +209,16 @@ still working?" without opening the widget or asking:
|
|
|
207
209
|
subagents 2 running · 1 repo lane · 3 done
|
|
208
210
|
```
|
|
209
211
|
|
|
210
|
-
It is count-only, keeps the same wait vocabulary as the widget,
|
|
211
|
-
|
|
212
|
+
It is count-only, keeps the same wait vocabulary as the widget, and works in
|
|
213
|
+
RPC hosts as well as the TUI. Settled counts stay on the line only while a
|
|
214
|
+
sibling is still live (`2 running · 3 done`); the line disappears once nothing
|
|
215
|
+
is active.
|
|
212
216
|
|
|
213
217
|
Completions resume the main agent on their own, with a compact block of at most 40
|
|
214
218
|
lines by default; longer output lands unchanged in a Markdown artifact whose path
|
|
215
219
|
comes with the message, stated as how much was actually cut (`40 of 137 lines
|
|
216
|
-
shown
|
|
220
|
+
shown`, or a 200-character clip when a short result has a long line) and
|
|
221
|
+
conditioned on the shown lines being insufficient, so the same
|
|
217
222
|
content does not enter the main context twice. Roles write result-only handoffs — outcome, paths,
|
|
218
223
|
verification, unresolved blockers — and the main agent is told to add its
|
|
219
224
|
conclusion rather than restate what you already read. A failed run adds its
|
|
@@ -363,13 +368,30 @@ beats an age rule.
|
|
|
363
368
|
```bash
|
|
364
369
|
npm install
|
|
365
370
|
npm run check
|
|
366
|
-
npm test
|
|
367
371
|
```
|
|
368
372
|
|
|
369
373
|
There are no bundled runtime dependencies; pi and TypeBox are peers. The source is
|
|
370
374
|
split by responsibility: dispatch policy, thread lifecycle, RPC
|
|
371
375
|
transport, worktree integration, completion delivery, tools, and TUI status.
|
|
372
376
|
|
|
377
|
+
## Release
|
|
378
|
+
|
|
379
|
+
Merging to `main` publishes `@ferris1225/pi-subagents` when `package.json`
|
|
380
|
+
carries a version npm does not have yet, then opens a matching GitHub Release.
|
|
381
|
+
Do not `npm publish` from a laptop.
|
|
382
|
+
|
|
383
|
+
One-time setup — pick one:
|
|
384
|
+
|
|
385
|
+
1. **Trusted publisher (preferred):** on the
|
|
386
|
+
[npm package page](https://www.npmjs.com/package/@ferris1225/pi-subagents)
|
|
387
|
+
add a GitHub Actions trusted publisher for `MCapricorns/pi-subagents` with
|
|
388
|
+
workflow filename `publish.yml` (no path, environment left blank).
|
|
389
|
+
2. **Token:** create an npm automation token and store it as the repository
|
|
390
|
+
Actions secret `NPM_TOKEN`.
|
|
391
|
+
|
|
392
|
+
If the first run fails because neither is configured, add the publisher or
|
|
393
|
+
secret and re-run the **Publish** workflow from the Actions tab.
|
|
394
|
+
|
|
373
395
|
## License
|
|
374
396
|
|
|
375
397
|
MIT
|
package/agents/executor.md
CHANGED
|
@@ -13,8 +13,9 @@ Repository instructions (AGENTS.md) and any skills available in this session app
|
|
|
13
13
|
|
|
14
14
|
1. **Context.** Read the brief fully, plus referenced files and images, before acting. If critical context is missing, state what is missing rather than guessing.
|
|
15
15
|
2. **Plan.** Inspect existing code and conventions first; form the smallest coherent root-cause change that satisfies the brief. Prefer the design that deletes complexity over one that rearranges it. No unrelated refactors or standalone docs work unless the brief asks.
|
|
16
|
-
3. **
|
|
17
|
-
4. **
|
|
16
|
+
3. **Confirm.** A finding is not a change. Re-read the current code and confirm each defect you are about to fix is real — not a misread, a stale report, or an intended tradeoff — even when the brief said "fix it". A false positive means zero edits and a note.
|
|
17
|
+
4. **Implement.** Preserve the user's work; limit edits to the request plus required validation. Follow the project's error handling, naming, and style. Synchronize README/docs/comments your change directly affects; never defer that drift.
|
|
18
|
+
5. **Verify.** Run the project's format/build/tests when they exist. NEVER report an unrun check as passed — report it as unavailable or a pre-existing failure, with the exact error.
|
|
18
19
|
|
|
19
20
|
## Conditional playbooks
|
|
20
21
|
|
|
@@ -44,7 +45,7 @@ What was done, in a few lines.
|
|
|
44
45
|
|
|
45
46
|
## Verification
|
|
46
47
|
|
|
47
|
-
Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean
|
|
48
|
+
Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean). State explicitly anything you could not run and why.
|
|
48
49
|
|
|
49
50
|
## Notes (only when material)
|
|
50
51
|
|
package/agents/explorer.md
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: explorer
|
|
3
|
-
description: Fast read-only reconnaissance for broad or multi-file search in unfamiliar areas; returns exact paths and compressed findings as retrieval leads.
|
|
4
|
-
tools: read, grep, find, ls, bash
|
|
5
|
-
# At launch, this shell slot follows the parent and parent-active plugin tools
|
|
6
|
-
# are appended; the listed non-shell Pi built-ins remain the permission boundary.
|
|
7
|
-
thinking: low
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
You are an explorer agent: a fast, read-only reconnaissance specialist. You investigate a codebase and return compressed, structured findings so another agent does not repeat the whole search. You have NOT got the caller's conversation history — the task brief is your only input.
|
|
11
|
-
|
|
12
|
-
## Hard constraints
|
|
13
|
-
|
|
14
|
-
- You are READ-ONLY. Never create, edit, or delete files; never run mutating commands. Reach for your `read`/`grep`/`find`/`ls` tools before the shell — they behave the same on every platform, while the shell you were given may be POSIX or PowerShell. Keep shell use to read-only inspection (`git log/show/diff/status` and that shell's own read-only commands); no installs, builds, or state changes. Permissions are not perfectly enforceable — keep every command strictly read-only by intent.
|
|
15
|
-
- Every finding is a retrieval lead, never sufficient proof for deletion, security claims, public/API compatibility, persistence, or other load-bearing decisions. The caller must re-read the cited line ranges before acting on your results.
|
|
16
|
-
|
|
17
|
-
## Workflow
|
|
18
|
-
|
|
19
|
-
1. Orient with `grep`/`find` to locate the relevant code fast. Prefer bare identifiers as patterns; scope by path and exclude noisy dirs (node_modules, dist, generated).
|
|
20
|
-
2. Read KEY SECTIONS, not whole files. After 1-2 greps, read the top match instead of running more greps.
|
|
21
|
-
3. Identify the types, interfaces, and key function signatures involved; note how files depend on each other.
|
|
22
|
-
4. Record exact paths and line ranges so the caller can jump straight in.
|
|
23
|
-
5. If the brief asks you to inspect images (screenshots, mockups, designs), `read` them — the model receives them as attachments when it supports vision.
|
|
24
|
-
|
|
25
|
-
Thoroughness scales with the task (default medium): quick = targeted lookups in key files; medium = follow imports and callers, read critical sections; thorough = trace dependencies across modules, check tests and types.
|
|
26
|
-
|
|
27
|
-
## Final response
|
|
28
|
-
|
|
29
|
-
Return only retrieval results, one bare bullet per finding — a single line: path, the fact, nothing else:
|
|
30
|
-
|
|
31
|
-
```text
|
|
32
|
-
- `path/to/file.ts:10-50` — the fact
|
|
33
|
-
Start here: `path/to/file.ts` — entry symbol and why (only when the caller could not guess it)
|
|
34
|
-
Gaps: unresolved uncertainty (only when real)
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
No preamble or closing summary. Do not repeat the task brief, inventory every file opened, paste nonessential code, or narrate the search; every line must carry a path with a fact or name a gap — delete anything else. State uncertainty and missing coverage — a plausible guess is more expensive than an honest gap. Stay under 15 lines by default; go longer only when the brief genuinely demands a wide survey — the 40-line delivery cap truncates your tail (usually the Gaps) and the caller pays for every line.
|
|
1
|
+
---
|
|
2
|
+
name: explorer
|
|
3
|
+
description: Fast read-only reconnaissance for broad or multi-file search in unfamiliar areas; returns exact paths and compressed findings as retrieval leads.
|
|
4
|
+
tools: read, grep, find, ls, bash
|
|
5
|
+
# At launch, this shell slot follows the parent and parent-active plugin tools
|
|
6
|
+
# are appended; the listed non-shell Pi built-ins remain the permission boundary.
|
|
7
|
+
thinking: low
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You are an explorer agent: a fast, read-only reconnaissance specialist. You investigate a codebase and return compressed, structured findings so another agent does not repeat the whole search. You have NOT got the caller's conversation history — the task brief is your only input.
|
|
11
|
+
|
|
12
|
+
## Hard constraints
|
|
13
|
+
|
|
14
|
+
- You are READ-ONLY. Never create, edit, or delete files; never run mutating commands. Reach for your `read`/`grep`/`find`/`ls` tools before the shell — they behave the same on every platform, while the shell you were given may be POSIX or PowerShell. Keep shell use to read-only inspection (`git log/show/diff/status` and that shell's own read-only commands); no installs, builds, or state changes. Permissions are not perfectly enforceable — keep every command strictly read-only by intent.
|
|
15
|
+
- Every finding is a retrieval lead, never sufficient proof for deletion, security claims, public/API compatibility, persistence, or other load-bearing decisions. The caller must re-read the cited line ranges before acting on your results.
|
|
16
|
+
|
|
17
|
+
## Workflow
|
|
18
|
+
|
|
19
|
+
1. Orient with `grep`/`find` to locate the relevant code fast. Prefer bare identifiers as patterns; scope by path and exclude noisy dirs (node_modules, dist, generated).
|
|
20
|
+
2. Read KEY SECTIONS, not whole files. After 1-2 greps, read the top match instead of running more greps.
|
|
21
|
+
3. Identify the types, interfaces, and key function signatures involved; note how files depend on each other.
|
|
22
|
+
4. Record exact paths and line ranges so the caller can jump straight in.
|
|
23
|
+
5. If the brief asks you to inspect images (screenshots, mockups, designs), `read` them — the model receives them as attachments when it supports vision.
|
|
24
|
+
|
|
25
|
+
Thoroughness scales with the task (default medium): quick = targeted lookups in key files; medium = follow imports and callers, read critical sections; thorough = trace dependencies across modules, check tests and types.
|
|
26
|
+
|
|
27
|
+
## Final response
|
|
28
|
+
|
|
29
|
+
Return only retrieval results, one bare bullet per finding — a single line: path, the fact, nothing else:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
- `path/to/file.ts:10-50` — the fact
|
|
33
|
+
Start here: `path/to/file.ts` — entry symbol and why (only when the caller could not guess it)
|
|
34
|
+
Gaps: unresolved uncertainty (only when real)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No preamble or closing summary. Do not repeat the task brief, inventory every file opened, paste nonessential code, or narrate the search; every line must carry a path with a fact or name a gap — delete anything else. State uncertainty and missing coverage — a plausible guess is more expensive than an honest gap. Stay under 15 lines by default; go longer only when the brief genuinely demands a wide survey — the 40-line delivery cap truncates your tail (usually the Gaps) and the caller pays for every line.
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.12",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/MCapricorns/pi-subagents.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/MCapricorns/pi-subagents/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MCapricorns/pi-subagents#readme",
|
|
7
15
|
"publishConfig": {
|
|
8
16
|
"access": "public"
|
|
9
17
|
},
|
package/src/background.ts
CHANGED
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded background task scheduler.
|
|
3
|
-
*
|
|
4
|
-
* Tasks get their own AbortSignal rather than inheriting the foreground agent
|
|
5
|
-
* turn's signal. The owning extension cancels all work only on session teardown.
|
|
6
|
-
*
|
|
7
|
-
* Task exceptions are never swallowed: the per-task onError callback receives
|
|
8
|
-
* them (unless the task was cancelled) so callers can surface the failure to
|
|
9
|
-
* the user and the main agent instead of it vanishing into the queue.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { cpus } from "node:os";
|
|
13
|
-
|
|
14
|
-
export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
|
|
15
|
-
|
|
16
|
-
interface PendingTask {
|
|
17
|
-
task: BackgroundTask;
|
|
18
|
-
controller: AbortController;
|
|
19
|
-
complete: () => void;
|
|
20
|
-
/** Called when a queued task is aborted before its body ever runs (drain skips
|
|
21
|
-
* an already-aborted entry; cancelAll aborts every pending entry), so the
|
|
22
|
-
* task body never produces a result. Callers that resolve waiters on a run id
|
|
23
|
-
* must register a synthetic result here (or via a stop path) — otherwise a
|
|
24
|
-
* waiter resolves via a "removed before its result was recorded" note. Never
|
|
25
|
-
* called for a task whose body already started; that path owns its result. */
|
|
26
|
-
onCancelled?: () => void;
|
|
27
|
-
/** Invoked when the task throws and was not cancelled (cancellation is not a
|
|
28
|
-
* failure — e.g. session shutdown races must never be reported as errors). */
|
|
29
|
-
onError?: (error: unknown) => void | Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** How many sub-agent processes may run at once, derived from the host instead
|
|
33
|
-
* of being fixed: children wait on model I/O far more than on CPU, so the pool
|
|
34
|
-
* scales with cores while the bounds keep tiny machines usable and huge ones
|
|
35
|
-
* from fanning out into an API-rate-limit wall. Pacing only — the queue never
|
|
36
|
-
* rejects work; a wider parallel `subagent` call simply waits for a slot. */
|
|
37
|
-
export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
|
|
38
|
-
return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class BackgroundTaskQueue {
|
|
42
|
-
private concurrency: number;
|
|
43
|
-
private readonly pending: PendingTask[] = [];
|
|
44
|
-
private readonly active = new Set<AbortController>();
|
|
45
|
-
/** Active tasks that no longer count toward the concurrency limit. They keep
|
|
46
|
-
* every other guarantee: abortable, awaited by waitForTask/waitForIdle. */
|
|
47
|
-
private readonly suspended = new Set<AbortController>();
|
|
48
|
-
private readonly completions = new WeakMap<AbortController, Promise<void>>();
|
|
49
|
-
private readonly idleWaiters = new Set<() => void>();
|
|
50
|
-
private stopped = false;
|
|
51
|
-
|
|
52
|
-
constructor(concurrency: number) {
|
|
53
|
-
this.concurrency = Math.max(1, concurrency);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
|
|
57
|
-
const controller = new AbortController();
|
|
58
|
-
let complete!: () => void;
|
|
59
|
-
const completion = new Promise<void>((resolve) => {
|
|
60
|
-
complete = resolve;
|
|
61
|
-
});
|
|
62
|
-
this.completions.set(controller, completion);
|
|
63
|
-
if (this.stopped) {
|
|
64
|
-
controller.abort();
|
|
65
|
-
this.runCancelled(onCancelled);
|
|
66
|
-
complete();
|
|
67
|
-
return controller;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
this.pending.push({ task, controller, complete, onCancelled, onError });
|
|
71
|
-
this.drain();
|
|
72
|
-
return controller;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Resolve after this exact task has left the pending/active sets. This is
|
|
76
|
-
* stronger than waiting for its task body: callers may safely reuse a
|
|
77
|
-
* concurrency slot or the task's persisted checkpoint after it resolves. */
|
|
78
|
-
waitForTask(controller: AbortController | undefined): Promise<void> {
|
|
79
|
-
if (!controller) return Promise.resolve();
|
|
80
|
-
return this.completions.get(controller) ?? Promise.resolve();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Slot count, exposed so dispatch/status output can state the real pacing
|
|
84
|
-
* limit instead of leaving queued work looking like an unexplained cap. */
|
|
85
|
-
get capacity(): number {
|
|
86
|
-
return this.concurrency;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Tasks still waiting for a free slot (never started). */
|
|
90
|
-
get pendingCount(): number {
|
|
91
|
-
return this.pending.length;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Tasks currently holding a slot. Suspended tasks (lane waits, managed
|
|
95
|
-
* workflow continuations) hold none and are excluded. */
|
|
96
|
-
get activeCount(): number {
|
|
97
|
-
return this.active.size;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Stop counting a running task toward the concurrency limit. Its body keeps
|
|
101
|
-
* running under the same abort signal; completion still releases everything
|
|
102
|
-
* waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
|
|
103
|
-
*
|
|
104
|
-
* Used by tasks whose execution is serialized elsewhere anyway (managed
|
|
105
|
-
* workflow continuations, shared-checkout writers waiting on the repository
|
|
106
|
-
* lane): letting such a task also hold a global slot would let waiters
|
|
107
|
-
* starve independent work that could start right away. The controller is
|
|
108
|
-
* handed to the task body directly, so a task can always suspend itself
|
|
109
|
-
* without racing the enqueue() caller's assignment. */
|
|
110
|
-
suspend(controller: AbortController | undefined): void {
|
|
111
|
-
if (!controller || this.stopped) return;
|
|
112
|
-
if (!this.active.delete(controller)) return;
|
|
113
|
-
this.suspended.add(controller);
|
|
114
|
-
this.drain();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Cancel one queued/running task. Queued entries are removed immediately;
|
|
118
|
-
* active entries resolve waitForTask only after their body and error handler
|
|
119
|
-
* have quiesced and the concurrency slot has been released. */
|
|
120
|
-
cancel(controller: AbortController | undefined): void {
|
|
121
|
-
if (!controller) return;
|
|
122
|
-
controller.abort();
|
|
123
|
-
const index = this.pending.findIndex((entry) => entry.controller === controller);
|
|
124
|
-
if (index !== -1) {
|
|
125
|
-
const [entry] = this.pending.splice(index, 1);
|
|
126
|
-
this.runCancelled(entry.onCancelled);
|
|
127
|
-
entry.complete();
|
|
128
|
-
this.drain();
|
|
129
|
-
this.resolveIdleWaiters();
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Resolve once no queued or running task remains. */
|
|
134
|
-
waitForIdle(): Promise<void> {
|
|
135
|
-
if (this.pending.length === 0 && this.active.size === 0 && this.suspended.size === 0) return Promise.resolve();
|
|
136
|
-
return new Promise<void>((resolve) => this.idleWaiters.add(resolve));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Stop queued work and request cancellation for running work. */
|
|
140
|
-
cancelAll(): void {
|
|
141
|
-
if (this.stopped) return;
|
|
142
|
-
this.stopped = true;
|
|
143
|
-
|
|
144
|
-
for (const entry of this.pending.splice(0)) {
|
|
145
|
-
entry.controller.abort();
|
|
146
|
-
this.runCancelled(entry.onCancelled);
|
|
147
|
-
entry.complete();
|
|
148
|
-
}
|
|
149
|
-
for (const controller of this.active) controller.abort();
|
|
150
|
-
for (const controller of this.suspended) controller.abort();
|
|
151
|
-
this.resolveIdleWaiters();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Cancellation callbacks are user-supplied: a throw must never break the queue
|
|
155
|
-
* (mirrors the try/catch around onError in drain). */
|
|
156
|
-
private runCancelled(callback: (() => void) | undefined): void {
|
|
157
|
-
if (!callback) return;
|
|
158
|
-
try {
|
|
159
|
-
callback();
|
|
160
|
-
} catch {
|
|
161
|
-
/* cancellation callbacks must never break the queue */
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
private drain(): void {
|
|
166
|
-
while (!this.stopped && this.active.size < this.concurrency) {
|
|
167
|
-
const entry = this.pending.shift();
|
|
168
|
-
if (!entry) {
|
|
169
|
-
this.resolveIdleWaiters();
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (entry.controller.signal.aborted) {
|
|
173
|
-
this.runCancelled(entry.onCancelled);
|
|
174
|
-
entry.complete();
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
this.active.add(entry.controller);
|
|
179
|
-
void entry.task(entry.controller.signal, entry.controller)
|
|
180
|
-
.catch(async (error: unknown) => {
|
|
181
|
-
// Cancellation is not a failure: aborted work (e.g. session
|
|
182
|
-
// shutdown) must never be reported as an exception.
|
|
183
|
-
if (entry.controller.signal.aborted) return;
|
|
184
|
-
try {
|
|
185
|
-
await entry.onError?.(error);
|
|
186
|
-
} catch {
|
|
187
|
-
/* error reporting must never break the queue */
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
.finally(() => {
|
|
191
|
-
this.active.delete(entry.controller);
|
|
192
|
-
this.suspended.delete(entry.controller);
|
|
193
|
-
entry.complete();
|
|
194
|
-
this.drain();
|
|
195
|
-
this.resolveIdleWaiters();
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private resolveIdleWaiters(): void {
|
|
201
|
-
if (this.pending.length > 0 || this.active.size > 0 || this.suspended.size > 0) return;
|
|
202
|
-
for (const resolve of this.idleWaiters) resolve();
|
|
203
|
-
this.idleWaiters.clear();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Bounded background task scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Tasks get their own AbortSignal rather than inheriting the foreground agent
|
|
5
|
+
* turn's signal. The owning extension cancels all work only on session teardown.
|
|
6
|
+
*
|
|
7
|
+
* Task exceptions are never swallowed: the per-task onError callback receives
|
|
8
|
+
* them (unless the task was cancelled) so callers can surface the failure to
|
|
9
|
+
* the user and the main agent instead of it vanishing into the queue.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cpus } from "node:os";
|
|
13
|
+
|
|
14
|
+
export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
interface PendingTask {
|
|
17
|
+
task: BackgroundTask;
|
|
18
|
+
controller: AbortController;
|
|
19
|
+
complete: () => void;
|
|
20
|
+
/** Called when a queued task is aborted before its body ever runs (drain skips
|
|
21
|
+
* an already-aborted entry; cancelAll aborts every pending entry), so the
|
|
22
|
+
* task body never produces a result. Callers that resolve waiters on a run id
|
|
23
|
+
* must register a synthetic result here (or via a stop path) — otherwise a
|
|
24
|
+
* waiter resolves via a "removed before its result was recorded" note. Never
|
|
25
|
+
* called for a task whose body already started; that path owns its result. */
|
|
26
|
+
onCancelled?: () => void;
|
|
27
|
+
/** Invoked when the task throws and was not cancelled (cancellation is not a
|
|
28
|
+
* failure — e.g. session shutdown races must never be reported as errors). */
|
|
29
|
+
onError?: (error: unknown) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How many sub-agent processes may run at once, derived from the host instead
|
|
33
|
+
* of being fixed: children wait on model I/O far more than on CPU, so the pool
|
|
34
|
+
* scales with cores while the bounds keep tiny machines usable and huge ones
|
|
35
|
+
* from fanning out into an API-rate-limit wall. Pacing only — the queue never
|
|
36
|
+
* rejects work; a wider parallel `subagent` call simply waits for a slot. */
|
|
37
|
+
export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
|
|
38
|
+
return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class BackgroundTaskQueue {
|
|
42
|
+
private concurrency: number;
|
|
43
|
+
private readonly pending: PendingTask[] = [];
|
|
44
|
+
private readonly active = new Set<AbortController>();
|
|
45
|
+
/** Active tasks that no longer count toward the concurrency limit. They keep
|
|
46
|
+
* every other guarantee: abortable, awaited by waitForTask/waitForIdle. */
|
|
47
|
+
private readonly suspended = new Set<AbortController>();
|
|
48
|
+
private readonly completions = new WeakMap<AbortController, Promise<void>>();
|
|
49
|
+
private readonly idleWaiters = new Set<() => void>();
|
|
50
|
+
private stopped = false;
|
|
51
|
+
|
|
52
|
+
constructor(concurrency: number) {
|
|
53
|
+
this.concurrency = Math.max(1, concurrency);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
let complete!: () => void;
|
|
59
|
+
const completion = new Promise<void>((resolve) => {
|
|
60
|
+
complete = resolve;
|
|
61
|
+
});
|
|
62
|
+
this.completions.set(controller, completion);
|
|
63
|
+
if (this.stopped) {
|
|
64
|
+
controller.abort();
|
|
65
|
+
this.runCancelled(onCancelled);
|
|
66
|
+
complete();
|
|
67
|
+
return controller;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.pending.push({ task, controller, complete, onCancelled, onError });
|
|
71
|
+
this.drain();
|
|
72
|
+
return controller;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve after this exact task has left the pending/active sets. This is
|
|
76
|
+
* stronger than waiting for its task body: callers may safely reuse a
|
|
77
|
+
* concurrency slot or the task's persisted checkpoint after it resolves. */
|
|
78
|
+
waitForTask(controller: AbortController | undefined): Promise<void> {
|
|
79
|
+
if (!controller) return Promise.resolve();
|
|
80
|
+
return this.completions.get(controller) ?? Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Slot count, exposed so dispatch/status output can state the real pacing
|
|
84
|
+
* limit instead of leaving queued work looking like an unexplained cap. */
|
|
85
|
+
get capacity(): number {
|
|
86
|
+
return this.concurrency;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Tasks still waiting for a free slot (never started). */
|
|
90
|
+
get pendingCount(): number {
|
|
91
|
+
return this.pending.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tasks currently holding a slot. Suspended tasks (lane waits, managed
|
|
95
|
+
* workflow continuations) hold none and are excluded. */
|
|
96
|
+
get activeCount(): number {
|
|
97
|
+
return this.active.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stop counting a running task toward the concurrency limit. Its body keeps
|
|
101
|
+
* running under the same abort signal; completion still releases everything
|
|
102
|
+
* waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
|
|
103
|
+
*
|
|
104
|
+
* Used by tasks whose execution is serialized elsewhere anyway (managed
|
|
105
|
+
* workflow continuations, shared-checkout writers waiting on the repository
|
|
106
|
+
* lane): letting such a task also hold a global slot would let waiters
|
|
107
|
+
* starve independent work that could start right away. The controller is
|
|
108
|
+
* handed to the task body directly, so a task can always suspend itself
|
|
109
|
+
* without racing the enqueue() caller's assignment. */
|
|
110
|
+
suspend(controller: AbortController | undefined): void {
|
|
111
|
+
if (!controller || this.stopped) return;
|
|
112
|
+
if (!this.active.delete(controller)) return;
|
|
113
|
+
this.suspended.add(controller);
|
|
114
|
+
this.drain();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Cancel one queued/running task. Queued entries are removed immediately;
|
|
118
|
+
* active entries resolve waitForTask only after their body and error handler
|
|
119
|
+
* have quiesced and the concurrency slot has been released. */
|
|
120
|
+
cancel(controller: AbortController | undefined): void {
|
|
121
|
+
if (!controller) return;
|
|
122
|
+
controller.abort();
|
|
123
|
+
const index = this.pending.findIndex((entry) => entry.controller === controller);
|
|
124
|
+
if (index !== -1) {
|
|
125
|
+
const [entry] = this.pending.splice(index, 1);
|
|
126
|
+
this.runCancelled(entry.onCancelled);
|
|
127
|
+
entry.complete();
|
|
128
|
+
this.drain();
|
|
129
|
+
this.resolveIdleWaiters();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolve once no queued or running task remains. */
|
|
134
|
+
waitForIdle(): Promise<void> {
|
|
135
|
+
if (this.pending.length === 0 && this.active.size === 0 && this.suspended.size === 0) return Promise.resolve();
|
|
136
|
+
return new Promise<void>((resolve) => this.idleWaiters.add(resolve));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stop queued work and request cancellation for running work. */
|
|
140
|
+
cancelAll(): void {
|
|
141
|
+
if (this.stopped) return;
|
|
142
|
+
this.stopped = true;
|
|
143
|
+
|
|
144
|
+
for (const entry of this.pending.splice(0)) {
|
|
145
|
+
entry.controller.abort();
|
|
146
|
+
this.runCancelled(entry.onCancelled);
|
|
147
|
+
entry.complete();
|
|
148
|
+
}
|
|
149
|
+
for (const controller of this.active) controller.abort();
|
|
150
|
+
for (const controller of this.suspended) controller.abort();
|
|
151
|
+
this.resolveIdleWaiters();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Cancellation callbacks are user-supplied: a throw must never break the queue
|
|
155
|
+
* (mirrors the try/catch around onError in drain). */
|
|
156
|
+
private runCancelled(callback: (() => void) | undefined): void {
|
|
157
|
+
if (!callback) return;
|
|
158
|
+
try {
|
|
159
|
+
callback();
|
|
160
|
+
} catch {
|
|
161
|
+
/* cancellation callbacks must never break the queue */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private drain(): void {
|
|
166
|
+
while (!this.stopped && this.active.size < this.concurrency) {
|
|
167
|
+
const entry = this.pending.shift();
|
|
168
|
+
if (!entry) {
|
|
169
|
+
this.resolveIdleWaiters();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (entry.controller.signal.aborted) {
|
|
173
|
+
this.runCancelled(entry.onCancelled);
|
|
174
|
+
entry.complete();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.active.add(entry.controller);
|
|
179
|
+
void entry.task(entry.controller.signal, entry.controller)
|
|
180
|
+
.catch(async (error: unknown) => {
|
|
181
|
+
// Cancellation is not a failure: aborted work (e.g. session
|
|
182
|
+
// shutdown) must never be reported as an exception.
|
|
183
|
+
if (entry.controller.signal.aborted) return;
|
|
184
|
+
try {
|
|
185
|
+
await entry.onError?.(error);
|
|
186
|
+
} catch {
|
|
187
|
+
/* error reporting must never break the queue */
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.finally(() => {
|
|
191
|
+
this.active.delete(entry.controller);
|
|
192
|
+
this.suspended.delete(entry.controller);
|
|
193
|
+
entry.complete();
|
|
194
|
+
this.drain();
|
|
195
|
+
this.resolveIdleWaiters();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private resolveIdleWaiters(): void {
|
|
201
|
+
if (this.pending.length > 0 || this.active.size > 0 || this.suspended.size > 0) return;
|
|
202
|
+
for (const resolve of this.idleWaiters) resolve();
|
|
203
|
+
this.idleWaiters.clear();
|
|
204
|
+
}
|
|
205
|
+
}
|