@agnishc/edb-subagents 0.8.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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial release — forked from tintinweb/pi-subagents and adapted for the edb monorepo
7
+ - Updated all imports from `@mariozechner/*` to `@earendil-works/*`
8
+ - Tool description now dynamically reflects only enabled agents (no hardcoded default-agent guidelines)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agnish Chakraborty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @agnishc/edb-subagents
2
+
3
+ Claude Code-style autonomous sub-agents for pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in foreground or background, steer them mid-run, resume completed sessions, and define your own custom agent types.
4
+
5
+ Forked from [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) and extended as part of the edb monorepo.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:@agnishc/edb-subagents
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Live widget** — persistent above-editor widget with animated spinners, live tool activity, token counts, and context utilisation
16
+ - **Parallel background agents** — spawn multiple agents concurrently with automatic queuing
17
+ - **Conversation viewer** — `/agents` → select a running agent to see its full conversation live
18
+ - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter
19
+ - **Mid-run steering** — inject messages into running agents via `steer_subagent`
20
+ - **Session resume** — continue a previous agent's full conversation context
21
+ - **Graceful turn limits** — agents wrap up cleanly before hard abort
22
+ - **Git worktree isolation** — run agents in isolated repo copies
23
+ - **Persistent agent memory** — three scopes: project, local, user
24
+ - **Scheduled agents** — cron, interval, and one-shot scheduling
25
+ - **Cross-extension RPC** — spawn/stop agents from other extensions via `pi.events`
26
+
27
+ ## Quick start
28
+
29
+ ```
30
+ Agent({
31
+ subagent_type: "Explore",
32
+ prompt: "Find all files that handle authentication",
33
+ description: "Find auth files",
34
+ run_in_background: true,
35
+ })
36
+ ```
37
+
38
+ ## Custom agents
39
+
40
+ Create `.pi/agents/<name>.md`:
41
+
42
+ ```markdown
43
+ ---
44
+ description: Security Code Reviewer
45
+ tools: read, grep, find, bash
46
+ model: anthropic/claude-opus-4-6
47
+ thinking: high
48
+ max_turns: 30
49
+ ---
50
+
51
+ You are a security auditor. Review code for vulnerabilities...
52
+ ```
53
+
54
+ ## Commands
55
+
56
+ | Command | Description |
57
+ |---|---|
58
+ | `/agents` | Interactive agent management — view running agents, manage types, create new agents, settings |
59
+
60
+ ## Attribution
61
+
62
+ Core implementation by [tintinweb](https://github.com/tintinweb/pi-subagents), MIT licensed.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@agnishc/edb-subagents",
3
+ "version": "0.8.2",
4
+ "description": "Pi extension: Claude Code-style autonomous sub-agents with live widget, parallel execution, mid-run steering, and custom agent types",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "edb",
9
+ "subagents",
10
+ "agents"
11
+ ],
12
+ "type": "module",
13
+ "license": "MIT",
14
+ "author": "Agnish Chakraborty",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
18
+ "directory": "packages/edb-subagents"
19
+ },
20
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-subagents#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/agnishcc/pi-extention-monorepo/issues"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/index.ts"
39
+ ]
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-agent-core": "*",
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "typebox": "*"
47
+ },
48
+ "dependencies": {
49
+ "croner": "^10.0.1",
50
+ "nanoid": "^5.0.0"
51
+ }
52
+ }
@@ -0,0 +1,547 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are queued and auto-started as running agents complete.
6
+ * Foreground agents bypass the queue (they block the parent anyway).
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { type RunResult, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
+ import { getAgentConfig } from "./agent-types.js";
14
+ import { resolveModel } from "./model-resolver.js";
15
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
16
+ import { addUsage } from "./usage.js";
17
+ import { cleanupWorktree, createWorktree, pruneWorktrees } from "./worktree.js";
18
+
19
+ export type OnAgentComplete = (record: AgentRecord) => void;
20
+ export type OnAgentStart = (record: AgentRecord) => void;
21
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
22
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
23
+
24
+ /** Default max concurrent background agents. */
25
+ const DEFAULT_MAX_CONCURRENT = 4;
26
+
27
+ interface SpawnArgs {
28
+ pi: ExtensionAPI;
29
+ ctx: ExtensionContext;
30
+ type: SubagentType;
31
+ prompt: string;
32
+ options: SpawnOptions;
33
+ }
34
+
35
+ interface SpawnOptions {
36
+ description: string;
37
+ model?: Model<any>;
38
+ maxTurns?: number;
39
+ isolated?: boolean;
40
+ inheritContext?: boolean;
41
+ thinkingLevel?: ThinkingLevel;
42
+ isBackground?: boolean;
43
+ /**
44
+ * Skip the maxConcurrent queue check for this spawn — start immediately even
45
+ * if the configured concurrency limit would otherwise queue it. Used by the
46
+ * scheduler so a fired job can't be deferred past its trigger window.
47
+ */
48
+ bypassQueue?: boolean;
49
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
50
+ isolation?: IsolationMode;
51
+ /** Ordered fallback models to try if the primary model fails with a provider error. */
52
+ fallbackModels?: string[];
53
+ /** Resolved invocation snapshot captured for UI display. */
54
+ invocation?: AgentInvocation;
55
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
56
+ signal?: AbortSignal;
57
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
58
+ onToolActivity?: (activity: ToolActivity) => void;
59
+ /** Called on streaming text deltas from the assistant response. */
60
+ onTextDelta?: (delta: string, fullText: string) => void;
61
+ /** Called when the agent session is created (for accessing session stats). */
62
+ onSessionCreated?: (session: AgentSession) => void;
63
+ /** Called at the end of each agentic turn with the cumulative count. */
64
+ onTurnEnd?: (turnCount: number) => void;
65
+ /** Called once per assistant message_end with that message's usage delta. */
66
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
67
+ /** Called when the session successfully compacts. */
68
+ onCompaction?: (info: CompactionInfo) => void;
69
+ }
70
+
71
+ /**
72
+ * Return true for provider-level failures that are worth retrying with a fallback model:
73
+ * rate limits, auth errors, model unavailability, server overload, and network timeouts.
74
+ * Ordinary task failures (tool errors, bad output, user aborts) are NOT retried.
75
+ */
76
+ function isProviderError(err: unknown): boolean {
77
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
78
+ return (
79
+ msg.includes("rate limit") ||
80
+ msg.includes("too many requests") ||
81
+ msg.includes("quota exceeded") ||
82
+ msg.includes("unauthorized") ||
83
+ msg.includes("authentication failed") ||
84
+ msg.includes("model not found") ||
85
+ msg.includes("model not available") ||
86
+ msg.includes("model not supported") ||
87
+ msg.includes("overloaded") ||
88
+ msg.includes("service unavailable") ||
89
+ msg.includes("timeout") ||
90
+ msg.includes("etimedout") ||
91
+ msg.includes("econnrefused") ||
92
+ msg.includes("econnreset") ||
93
+ /\b(401|429|503|529)\b/.test(msg)
94
+ );
95
+ }
96
+
97
+ export class AgentManager {
98
+ private agents = new Map<string, AgentRecord>();
99
+ private cleanupInterval: ReturnType<typeof setInterval>;
100
+ private onComplete?: OnAgentComplete;
101
+ private onStart?: OnAgentStart;
102
+ private onCompact?: OnAgentCompact;
103
+ private maxConcurrent: number;
104
+
105
+ /** Queue of background agents waiting to start. */
106
+ private queue: { id: string; args: SpawnArgs }[] = [];
107
+ /** Number of currently running background agents. */
108
+ private runningBackground = 0;
109
+
110
+ constructor(
111
+ onComplete?: OnAgentComplete,
112
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
113
+ onStart?: OnAgentStart,
114
+ onCompact?: OnAgentCompact,
115
+ ) {
116
+ this.onComplete = onComplete;
117
+ this.onStart = onStart;
118
+ this.onCompact = onCompact;
119
+ this.maxConcurrent = maxConcurrent;
120
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
121
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
122
+ this.cleanupInterval.unref();
123
+ }
124
+
125
+ /** Update the max concurrent background agents limit. */
126
+ setMaxConcurrent(n: number) {
127
+ this.maxConcurrent = Math.max(1, n);
128
+ // Start queued agents if the new limit allows
129
+ this.drainQueue();
130
+ }
131
+
132
+ getMaxConcurrent(): number {
133
+ return this.maxConcurrent;
134
+ }
135
+
136
+ /**
137
+ * Spawn an agent and return its ID immediately (for background use).
138
+ * If the concurrency limit is reached, the agent is queued.
139
+ */
140
+ spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string {
141
+ const id = randomUUID().slice(0, 17);
142
+ const abortController = new AbortController();
143
+ const record: AgentRecord = {
144
+ id,
145
+ type,
146
+ description: options.description,
147
+ status: options.isBackground ? "queued" : "running",
148
+ toolUses: 0,
149
+ startedAt: Date.now(),
150
+ abortController,
151
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
152
+ compactionCount: 0,
153
+ invocation: options.invocation,
154
+ };
155
+ this.agents.set(id, record);
156
+
157
+ const args: SpawnArgs = { pi, ctx, type, prompt, options };
158
+
159
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
160
+ // Queue it — will be started when a running agent completes
161
+ this.queue.push({ id, args });
162
+ return id;
163
+ }
164
+
165
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
166
+ // up the record so callers don't see an orphan in `listAgents()`.
167
+ try {
168
+ this.startAgent(id, record, args);
169
+ } catch (err) {
170
+ this.agents.delete(id);
171
+ throw err;
172
+ }
173
+ return id;
174
+ }
175
+
176
+ /** Actually start an agent (called immediately or from queue drain). */
177
+ private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
178
+ // Worktree isolation: try to create a temporary git worktree. Strict —
179
+ // fail loud if not possible (no silent fallback to main tree). Done
180
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
181
+ let worktreeCwd: string | undefined;
182
+ if (options.isolation === "worktree") {
183
+ const wt = createWorktree(ctx.cwd, id);
184
+ if (!wt) {
185
+ throw new Error(
186
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
187
+ "Initialize git and commit at least once, or omit `isolation`.",
188
+ );
189
+ }
190
+ record.worktree = wt;
191
+ worktreeCwd = wt.path;
192
+ }
193
+
194
+ record.status = "running";
195
+ record.startedAt = Date.now();
196
+ if (options.isBackground) this.runningBackground++;
197
+ this.onStart?.(record);
198
+
199
+ // Wire parent abort signal to stop the subagent when the parent is interrupted
200
+ let detachParentSignal: (() => void) | undefined;
201
+ if (options.signal) {
202
+ const onParentAbort = () => this.abort(id);
203
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
204
+ detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
205
+ }
206
+ const detach = () => {
207
+ detachParentSignal?.();
208
+ detachParentSignal = undefined;
209
+ };
210
+
211
+ // Build shared run options — model is overridden per attempt in the retry loop below.
212
+ const sharedRunOptions = {
213
+ pi,
214
+ agentId: id,
215
+ maxTurns: options.maxTurns,
216
+ isolated: options.isolated,
217
+ inheritContext: options.inheritContext,
218
+ thinkingLevel: options.thinkingLevel,
219
+ cwd: worktreeCwd,
220
+ signal: record.abortController!.signal,
221
+ onToolActivity: (activity: ToolActivity) => {
222
+ if (activity.type === "end") record.toolUses++;
223
+ options.onToolActivity?.(activity);
224
+ },
225
+ onTurnEnd: options.onTurnEnd,
226
+ onTextDelta: options.onTextDelta,
227
+ onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
228
+ addUsage(record.lifetimeUsage, usage);
229
+ options.onAssistantUsage?.(usage);
230
+ },
231
+ onCompaction: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => {
232
+ record.compactionCount++;
233
+ this.onCompact?.(record, info);
234
+ options.onCompaction?.(info);
235
+ },
236
+ onSessionCreated: (session: AgentSession) => {
237
+ record.session = session;
238
+ // Flush any steers that arrived before the session was ready
239
+ if (record.pendingSteers?.length) {
240
+ for (const msg of record.pendingSteers) {
241
+ session.steer(msg).catch(() => {});
242
+ }
243
+ record.pendingSteers = undefined;
244
+ }
245
+ options.onSessionCreated?.(session);
246
+ },
247
+ };
248
+
249
+ // Build ordered model list: primary first, then resolved fallbacks.
250
+ // Combine tool-param fallbacks (higher priority) with agent-config fallbacks.
251
+ const configFallbacks = getAgentConfig(type)?.fallbackModels ?? [];
252
+ const allFallbackStrings = [...(options.fallbackModels ?? []), ...configFallbacks];
253
+ const resolvedFallbacks = allFallbackStrings
254
+ .map((fb) => resolveModel(fb, ctx.modelRegistry))
255
+ .filter((r): r is Model<any> => typeof r !== "string");
256
+ const modelsToTry = [options.model, ...resolvedFallbacks];
257
+
258
+ // Run with automatic fallback on provider errors.
259
+ const runWithFallback = async (): Promise<RunResult> => {
260
+ let lastErr: unknown;
261
+ for (let i = 0; i < modelsToTry.length; i++) {
262
+ try {
263
+ return await runAgent(ctx, type, prompt, { ...sharedRunOptions, model: modelsToTry[i] });
264
+ } catch (err) {
265
+ lastErr = err;
266
+ if (i < modelsToTry.length - 1 && isProviderError(err)) continue;
267
+ throw err;
268
+ }
269
+ }
270
+ throw lastErr;
271
+ };
272
+
273
+ const promise = runWithFallback()
274
+ .then(({ responseText, session, aborted, steered }) => {
275
+ // Don't overwrite status if externally stopped via abort()
276
+ if (record.status !== "stopped") {
277
+ record.status = aborted ? "aborted" : steered ? "steered" : "completed";
278
+ }
279
+ record.result = responseText;
280
+ record.session = session;
281
+ record.completedAt ??= Date.now();
282
+
283
+ detach();
284
+
285
+ // Final flush of streaming output file
286
+ if (record.outputCleanup) {
287
+ try {
288
+ record.outputCleanup();
289
+ } catch {
290
+ /* ignore */
291
+ }
292
+ record.outputCleanup = undefined;
293
+ }
294
+
295
+ // Clean up worktree if used
296
+ if (record.worktree) {
297
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
298
+ record.worktreeResult = wtResult;
299
+ if (wtResult.hasChanges && wtResult.branch) {
300
+ record.result =
301
+ (record.result ?? "") +
302
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
303
+ }
304
+ }
305
+
306
+ if (options.isBackground) {
307
+ this.runningBackground--;
308
+ try {
309
+ this.onComplete?.(record);
310
+ } catch {
311
+ /* ignore completion side-effect errors */
312
+ }
313
+ this.drainQueue();
314
+ }
315
+ return responseText;
316
+ })
317
+ .catch((err) => {
318
+ // Don't overwrite status if externally stopped via abort()
319
+ if (record.status !== "stopped") {
320
+ record.status = "error";
321
+ }
322
+ record.error = err instanceof Error ? err.message : String(err);
323
+ record.completedAt ??= Date.now();
324
+
325
+ detach();
326
+
327
+ // Final flush of streaming output file on error
328
+ if (record.outputCleanup) {
329
+ try {
330
+ record.outputCleanup();
331
+ } catch {
332
+ /* ignore */
333
+ }
334
+ record.outputCleanup = undefined;
335
+ }
336
+
337
+ // Best-effort worktree cleanup on error
338
+ if (record.worktree) {
339
+ try {
340
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
341
+ record.worktreeResult = wtResult;
342
+ } catch {
343
+ /* ignore cleanup errors */
344
+ }
345
+ }
346
+
347
+ if (options.isBackground) {
348
+ this.runningBackground--;
349
+ this.onComplete?.(record);
350
+ this.drainQueue();
351
+ }
352
+ return "";
353
+ });
354
+
355
+ record.promise = promise;
356
+ }
357
+
358
+ /** Start queued agents up to the concurrency limit. */
359
+ private drainQueue() {
360
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
361
+ const next = this.queue.shift()!;
362
+ const record = this.agents.get(next.id);
363
+ if (!record || record.status !== "queued") continue;
364
+ try {
365
+ this.startAgent(next.id, record, next.args);
366
+ } catch (err) {
367
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
368
+ // so the user/agent can see it via /agents, then keep draining.
369
+ record.status = "error";
370
+ record.error = err instanceof Error ? err.message : String(err);
371
+ record.completedAt = Date.now();
372
+ this.onComplete?.(record);
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Spawn an agent and wait for completion (foreground use).
379
+ * Foreground agents bypass the concurrency queue.
380
+ */
381
+ async spawnAndWait(
382
+ pi: ExtensionAPI,
383
+ ctx: ExtensionContext,
384
+ type: SubagentType,
385
+ prompt: string,
386
+ options: Omit<SpawnOptions, "isBackground">,
387
+ ): Promise<AgentRecord> {
388
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
389
+ const record = this.agents.get(id)!;
390
+ await record.promise;
391
+ return record;
392
+ }
393
+
394
+ /**
395
+ * Resume an existing agent session with a new prompt.
396
+ */
397
+ async resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined> {
398
+ const record = this.agents.get(id);
399
+ if (!record?.session) return undefined;
400
+
401
+ record.status = "running";
402
+ record.startedAt = Date.now();
403
+ record.completedAt = undefined;
404
+ record.result = undefined;
405
+ record.error = undefined;
406
+
407
+ try {
408
+ const responseText = await resumeAgent(record.session, prompt, {
409
+ onToolActivity: (activity) => {
410
+ if (activity.type === "end") record.toolUses++;
411
+ },
412
+ onAssistantUsage: (usage) => {
413
+ addUsage(record.lifetimeUsage, usage);
414
+ },
415
+ onCompaction: (info) => {
416
+ record.compactionCount++;
417
+ this.onCompact?.(record, info);
418
+ },
419
+ signal,
420
+ });
421
+ record.status = "completed";
422
+ record.result = responseText;
423
+ record.completedAt = Date.now();
424
+ } catch (err) {
425
+ record.status = "error";
426
+ record.error = err instanceof Error ? err.message : String(err);
427
+ record.completedAt = Date.now();
428
+ }
429
+
430
+ return record;
431
+ }
432
+
433
+ getRecord(id: string): AgentRecord | undefined {
434
+ return this.agents.get(id);
435
+ }
436
+
437
+ listAgents(): AgentRecord[] {
438
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
439
+ }
440
+
441
+ abort(id: string): boolean {
442
+ const record = this.agents.get(id);
443
+ if (!record) return false;
444
+
445
+ // Remove from queue if queued
446
+ if (record.status === "queued") {
447
+ this.queue = this.queue.filter((q) => q.id !== id);
448
+ record.status = "stopped";
449
+ record.completedAt = Date.now();
450
+ return true;
451
+ }
452
+
453
+ if (record.status !== "running") return false;
454
+ record.abortController?.abort();
455
+ record.status = "stopped";
456
+ record.completedAt = Date.now();
457
+ return true;
458
+ }
459
+
460
+ /** Dispose a record's session and remove it from the map. */
461
+ private removeRecord(id: string, record: AgentRecord): void {
462
+ record.session?.dispose?.();
463
+ record.session = undefined;
464
+ this.agents.delete(id);
465
+ }
466
+
467
+ private cleanup() {
468
+ const cutoff = Date.now() - 10 * 60_000;
469
+ for (const [id, record] of this.agents) {
470
+ if (record.status === "running" || record.status === "queued") continue;
471
+ if ((record.completedAt ?? 0) >= cutoff) continue;
472
+ this.removeRecord(id, record);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Remove all completed/stopped/errored records immediately.
478
+ * Called on session start/switch so tasks from a prior session don't persist.
479
+ */
480
+ clearCompleted(): void {
481
+ for (const [id, record] of this.agents) {
482
+ if (record.status === "running" || record.status === "queued") continue;
483
+ this.removeRecord(id, record);
484
+ }
485
+ }
486
+
487
+ /** Whether any agents are still running or queued. */
488
+ hasRunning(): boolean {
489
+ return [...this.agents.values()].some((r) => r.status === "running" || r.status === "queued");
490
+ }
491
+
492
+ /** Abort all running and queued agents immediately. */
493
+ abortAll(): number {
494
+ let count = 0;
495
+ // Clear queued agents first
496
+ for (const queued of this.queue) {
497
+ const record = this.agents.get(queued.id);
498
+ if (record) {
499
+ record.status = "stopped";
500
+ record.completedAt = Date.now();
501
+ count++;
502
+ }
503
+ }
504
+ this.queue = [];
505
+ // Abort running agents
506
+ for (const record of this.agents.values()) {
507
+ if (record.status === "running") {
508
+ record.abortController?.abort();
509
+ record.status = "stopped";
510
+ record.completedAt = Date.now();
511
+ count++;
512
+ }
513
+ }
514
+ return count;
515
+ }
516
+
517
+ /** Wait for all running and queued agents to complete (including queued ones). */
518
+ async waitForAll(): Promise<void> {
519
+ // Loop because drainQueue respects the concurrency limit — as running
520
+ // agents finish they start queued ones, which need awaiting too.
521
+ while (true) {
522
+ this.drainQueue();
523
+ const pending = [...this.agents.values()]
524
+ .filter((r) => r.status === "running" || r.status === "queued")
525
+ .map((r) => r.promise)
526
+ .filter(Boolean);
527
+ if (pending.length === 0) break;
528
+ await Promise.allSettled(pending);
529
+ }
530
+ }
531
+
532
+ dispose() {
533
+ clearInterval(this.cleanupInterval);
534
+ // Clear queue
535
+ this.queue = [];
536
+ for (const record of this.agents.values()) {
537
+ record.session?.dispose();
538
+ }
539
+ this.agents.clear();
540
+ // Prune any orphaned git worktrees (crash recovery)
541
+ try {
542
+ pruneWorktrees(process.cwd());
543
+ } catch {
544
+ /* ignore */
545
+ }
546
+ }
547
+ }