@ferris1225/pi-subagents 4.2.8 → 4.2.13
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/CHANGELOG.md +61 -0
- package/LICENSE +23 -23
- package/README.md +60 -4
- package/agents/executor.md +4 -3
- package/agents/explorer.md +37 -37
- package/package.json +11 -2
- 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/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
|
+
}
|