@gotgenes/pi-subagents 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.
Files changed (86) hide show
  1. package/.markdownlint-cli2.yaml +19 -0
  2. package/.prettierignore +5 -0
  3. package/.release-please-manifest.json +3 -0
  4. package/AGENTS.md +85 -0
  5. package/CHANGELOG.md +495 -0
  6. package/LICENSE +21 -0
  7. package/README.md +528 -0
  8. package/dist/agent-manager.d.ts +108 -0
  9. package/dist/agent-manager.js +390 -0
  10. package/dist/agent-runner.d.ts +93 -0
  11. package/dist/agent-runner.js +428 -0
  12. package/dist/agent-types.d.ts +48 -0
  13. package/dist/agent-types.js +136 -0
  14. package/dist/context.d.ts +12 -0
  15. package/dist/context.js +56 -0
  16. package/dist/cross-extension-rpc.d.ts +46 -0
  17. package/dist/cross-extension-rpc.js +54 -0
  18. package/dist/custom-agents.d.ts +14 -0
  19. package/dist/custom-agents.js +127 -0
  20. package/dist/default-agents.d.ts +7 -0
  21. package/dist/default-agents.js +119 -0
  22. package/dist/env.d.ts +6 -0
  23. package/dist/env.js +28 -0
  24. package/dist/group-join.d.ts +32 -0
  25. package/dist/group-join.js +116 -0
  26. package/dist/index.d.ts +13 -0
  27. package/dist/index.js +1731 -0
  28. package/dist/invocation-config.d.ts +22 -0
  29. package/dist/invocation-config.js +15 -0
  30. package/dist/memory.d.ts +49 -0
  31. package/dist/memory.js +151 -0
  32. package/dist/model-resolver.d.ts +19 -0
  33. package/dist/model-resolver.js +62 -0
  34. package/dist/output-file.d.ts +24 -0
  35. package/dist/output-file.js +86 -0
  36. package/dist/prompts.d.ts +29 -0
  37. package/dist/prompts.js +72 -0
  38. package/dist/schedule-store.d.ts +36 -0
  39. package/dist/schedule-store.js +144 -0
  40. package/dist/schedule.d.ts +109 -0
  41. package/dist/schedule.js +338 -0
  42. package/dist/settings.d.ts +66 -0
  43. package/dist/settings.js +130 -0
  44. package/dist/skill-loader.d.ts +24 -0
  45. package/dist/skill-loader.js +93 -0
  46. package/dist/types.d.ts +164 -0
  47. package/dist/types.js +5 -0
  48. package/dist/ui/agent-widget.d.ts +134 -0
  49. package/dist/ui/agent-widget.js +451 -0
  50. package/dist/ui/conversation-viewer.d.ts +35 -0
  51. package/dist/ui/conversation-viewer.js +252 -0
  52. package/dist/ui/schedule-menu.d.ts +16 -0
  53. package/dist/ui/schedule-menu.js +95 -0
  54. package/dist/usage.d.ts +50 -0
  55. package/dist/usage.js +49 -0
  56. package/dist/worktree.d.ts +36 -0
  57. package/dist/worktree.js +139 -0
  58. package/docs/decisions/0001-deferred-patches.md +75 -0
  59. package/package.json +68 -0
  60. package/prek.toml +24 -0
  61. package/release-please-config.json +22 -0
  62. package/src/agent-manager.ts +482 -0
  63. package/src/agent-runner.ts +625 -0
  64. package/src/agent-types.ts +164 -0
  65. package/src/context.ts +58 -0
  66. package/src/cross-extension-rpc.ts +95 -0
  67. package/src/custom-agents.ts +136 -0
  68. package/src/default-agents.ts +123 -0
  69. package/src/env.ts +33 -0
  70. package/src/group-join.ts +141 -0
  71. package/src/index.ts +1894 -0
  72. package/src/invocation-config.ts +40 -0
  73. package/src/memory.ts +165 -0
  74. package/src/model-resolver.ts +81 -0
  75. package/src/output-file.ts +96 -0
  76. package/src/prompts.ts +105 -0
  77. package/src/schedule-store.ts +143 -0
  78. package/src/schedule.ts +365 -0
  79. package/src/settings.ts +186 -0
  80. package/src/skill-loader.ts +102 -0
  81. package/src/types.ts +176 -0
  82. package/src/ui/agent-widget.ts +533 -0
  83. package/src/ui/conversation-viewer.ts +261 -0
  84. package/src/ui/schedule-menu.ts +104 -0
  85. package/src/usage.ts +60 -0
  86. package/src/worktree.ts +162 -0
@@ -0,0 +1,75 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-05-11
4
+ ---
5
+
6
+ # 0001 — Deferred fork patches and upstream-PR strategy
7
+
8
+ ## Status
9
+
10
+ Accepted
11
+
12
+ ## Context
13
+
14
+ This fork was created to land three pieces of work identified during RepOne issue [#442](https://github.com/Tiny-IG-Software/repone/issues/442):
15
+
16
+ 1. **Peer-dep rename** — `@mariozechner/pi-*` → `@earendil-works/pi-*`.
17
+ 2. **Patch 2 — Re-activate extension tools post-`bindExtensions`** (Spike 3 finding).
18
+ 3. **Patch 3 — Inject `<active_agent>` tag** (Spike 4 finding).
19
+
20
+ A fourth piece of work was scoped during the same spike round but deferred:
21
+
22
+ - **Patch 1 — Mirror parent's `additionalExtensionPaths` (and siblings) into the child's `DefaultResourceLoader`** (Spike 2 finding).
23
+
24
+ This ADR records why Patch 1 was deferred and the strategy for upstream PRs back to [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents).
25
+
26
+ ## Decision
27
+
28
+ ### Patch 1 is deferred
29
+
30
+ The original Spike 2 finding was that the parent's `additionalExtensionPaths` does not propagate to the child's `DefaultResourceLoader`. The fix was sketched as "plumb parent's `additionalExtensionPaths` (and siblings) into the child."
31
+
32
+ During planning for this fork, two implementation constraints surfaced:
33
+
34
+ 1. The parent's `DefaultResourceLoader.additionalExtensionPaths` is **private** — no public getter on `ExtensionContext`.
35
+ 2. The parent's CLI flags (e.g., `pi -e <path>`) are parsed in `main.js` and not surfaced through any extension API.
36
+
37
+ A working patch would have to either:
38
+
39
+ - Accept new fields in `RunOptions` so callers supply the paths explicitly, **or**
40
+ - Reach into `process.argv` to re-resolve `-e`/`--extensions` flags from the child's perspective.
41
+
42
+ Neither matches the production need. For RepOne (and any consumer that installs extensions via `pi install`), extensions are settings-discoverable: children inherit them independently of the parent's `DefaultResourceLoader` configuration. The `pi -e <path>` ephemeral-extension case is the only beneficiary of Patch 1, and it does not appear in our workflow.
43
+
44
+ We therefore defer Patch 1 rather than carry a speculative patch in the fork's diff against upstream. A follow-up issue on the RepOne board (linked from #443) captures the criterion for revisiting: **a workflow that needs `pi -e <path>` ephemeral extensions to reach children**.
45
+
46
+ ### Upstream PRs are deferred
47
+
48
+ Patches 2 and 3 are both clearly upstream-mergeable bug fixes — they finish a mirror the upstream fork already started (carrying the parent's session configuration through to the child). The natural place for them is in `tintinweb/pi-subagents` itself.
49
+
50
+ We defer opening the PRs until **Patches 2 and 3 have been validated end-to-end in production** through at least one milestone of RepOne usage. The reasoning:
51
+
52
+ 1. Production validation is stronger evidence for the upstream maintainer than the spike findings alone.
53
+ 2. The patch shapes (especially Patch 2's helper-extraction refactor and Patch 3's exact prepend point) may need adjustment based on real-world behavior; opening PRs prematurely risks needing to amend them under review.
54
+ 3. Carrying the fork is low-cost while we iterate; the publishing infrastructure mirrors the other Pi siblings.
55
+
56
+ A follow-up issue on the RepOne board (linked from #443) tracks the upstream-PR work and the criterion for proceeding.
57
+
58
+ ## Consequences
59
+
60
+ ### Positive
61
+
62
+ - The fork's diff against upstream stays minimal — three patches plus tooling alignment.
63
+ - We avoid landing a speculative Patch 1 that would need rework if upstream's `ExtensionContext` API changes.
64
+ - We get production evidence before asking the upstream maintainer to review.
65
+
66
+ ### Negative
67
+
68
+ - The `pi -e <path>` ephemeral-extension case in subagents will not work until Patch 1 lands. We accept this because no consumer in scope uses that pattern.
69
+ - Patches 2 and 3 stay carried in `@gotgenes/pi-subagents` rather than upstream. Consumers must use this fork (not the upstream package) to get the patches.
70
+
71
+ ### Operational
72
+
73
+ - A follow-up issue on the RepOne board (linked from this fork's `README.md` "Deviations from upstream" section and from RepOne issue #443) records both deferrals.
74
+ - When upstream PRs are eventually opened, they should be opened separately for Patches 2 and 3 to keep review simple.
75
+ - When Patch 1 is eventually added, it should be a separate ADR in `docs/decisions/` with its own follow-up.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@gotgenes/pi-subagents",
3
+ "version": "1.0.0",
4
+ "description": "A pi extension that brings Claude Code-style autonomous sub-agents to pi. Friendly fork of @tintinweb/pi-subagents.",
5
+ "author": {
6
+ "name": "Chris Lasher"
7
+ },
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/gotgenes/pi-subagents.git"
12
+ },
13
+ "homepage": "https://github.com/gotgenes/pi-subagents#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/gotgenes/pi-subagents/issues"
16
+ },
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi",
20
+ "pi-extension",
21
+ "subagent",
22
+ "agent",
23
+ "autonomous"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": ">=0.74.0",
30
+ "@earendil-works/pi-coding-agent": ">=0.74.0",
31
+ "@earendil-works/pi-tui": ">=0.74.0"
32
+ },
33
+ "dependencies": {
34
+ "@sinclair/typebox": "^0.34.49",
35
+ "croner": "^10.0.1",
36
+ "nanoid": "^5.0.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "^2.4.14",
43
+ "@types/node": "^25.5.0",
44
+ "markdownlint-cli2": "^0.22.1",
45
+ "typescript": "^6.0.0",
46
+ "vitest": "^4.0.18"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./src/index.ts"
51
+ ],
52
+ "video": "https://github.com/gotgenes/pi-subagents/raw/main/media/demo.mp4",
53
+ "image": "https://github.com/gotgenes/pi-subagents/raw/main/media/screenshot.png"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "typecheck": "tsc --noEmit",
60
+ "lint": "biome check src/ test/",
61
+ "lint:fix": "biome check --fix src/ test/",
62
+ "lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md'",
63
+ "lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md'",
64
+ "lint:all": "pnpm run lint && pnpm run lint:md",
65
+ "format": "biome format --write src/ test/",
66
+ "check": "pnpm run build && pnpm run lint:all && pnpm run test"
67
+ }
68
+ }
package/prek.toml ADDED
@@ -0,0 +1,24 @@
1
+ # Configuration file for `prek`, a git hook framework written in Rust.
2
+ # See https://prek.j178.dev for more information.
3
+ #:schema https://www.schemastore.org/prek.json
4
+
5
+ [[repos]]
6
+ repo = "builtin"
7
+ hooks = [
8
+ { id = "trailing-whitespace" },
9
+ { id = "end-of-file-fixer" },
10
+ { id = "check-added-large-files" },
11
+ ]
12
+
13
+ [[repos]]
14
+ repo = "local"
15
+ hooks = [
16
+ { id = "biome", name = "biome", entry = "pnpm exec biome check --files-ignore-unknown=true --no-errors-on-unmatched", language = "system", types_or = ["javascript", "jsx", "ts", "tsx", "json"] },
17
+ ]
18
+
19
+ [[repos]]
20
+ repo = "https://github.com/DavidAnson/markdownlint-cli2"
21
+ rev = "v0.22.1"
22
+ hooks = [
23
+ { id = "markdownlint-cli2" },
24
+ ]
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {}
5
+ },
6
+ "include-v-in-tag": true,
7
+ "include-component-in-tag": false,
8
+ "release-type": "node",
9
+ "changelog-sections": [
10
+ { "type": "feat", "section": "Features" },
11
+ { "type": "fix", "section": "Bug Fixes" },
12
+ { "type": "perf", "section": "Performance Improvements" },
13
+ { "type": "revert", "section": "Reverts" },
14
+ { "type": "docs", "section": "Documentation" },
15
+ { "type": "style", "section": "Styles", "hidden": true },
16
+ { "type": "chore", "section": "Miscellaneous Chores" },
17
+ { "type": "refactor", "section": "Code Refactoring", "hidden": true },
18
+ { "type": "test", "section": "Tests", "hidden": true },
19
+ { "type": "build", "section": "Build System", "hidden": true },
20
+ { "type": "ci", "section": "Continuous Integration", "hidden": true }
21
+ ]
22
+ }
@@ -0,0 +1,482 @@
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 { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
14
+ import { addUsage } from "./usage.js";
15
+ import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
16
+
17
+ export type OnAgentComplete = (record: AgentRecord) => void;
18
+ export type OnAgentStart = (record: AgentRecord) => void;
19
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
20
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
21
+
22
+ /** Default max concurrent background agents. */
23
+ const DEFAULT_MAX_CONCURRENT = 4;
24
+
25
+ interface SpawnArgs {
26
+ pi: ExtensionAPI;
27
+ ctx: ExtensionContext;
28
+ type: SubagentType;
29
+ prompt: string;
30
+ options: SpawnOptions;
31
+ }
32
+
33
+ interface SpawnOptions {
34
+ description: string;
35
+ model?: Model<any>;
36
+ maxTurns?: number;
37
+ isolated?: boolean;
38
+ inheritContext?: boolean;
39
+ thinkingLevel?: ThinkingLevel;
40
+ isBackground?: boolean;
41
+ /**
42
+ * Skip the maxConcurrent queue check for this spawn — start immediately even
43
+ * if the configured concurrency limit would otherwise queue it. Used by the
44
+ * scheduler so a fired job can't be deferred past its trigger window.
45
+ */
46
+ bypassQueue?: boolean;
47
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
48
+ isolation?: IsolationMode;
49
+ /** Resolved invocation snapshot captured for UI display. */
50
+ invocation?: AgentInvocation;
51
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
52
+ signal?: AbortSignal;
53
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
54
+ onToolActivity?: (activity: ToolActivity) => void;
55
+ /** Called on streaming text deltas from the assistant response. */
56
+ onTextDelta?: (delta: string, fullText: string) => void;
57
+ /** Called when the agent session is created (for accessing session stats). */
58
+ onSessionCreated?: (session: AgentSession) => void;
59
+ /** Called at the end of each agentic turn with the cumulative count. */
60
+ onTurnEnd?: (turnCount: number) => void;
61
+ /** Called once per assistant message_end with that message's usage delta. */
62
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
63
+ /** Called when the session successfully compacts. */
64
+ onCompaction?: (info: CompactionInfo) => void;
65
+ }
66
+
67
+ export class AgentManager {
68
+ private agents = new Map<string, AgentRecord>();
69
+ private cleanupInterval: ReturnType<typeof setInterval>;
70
+ private onComplete?: OnAgentComplete;
71
+ private onStart?: OnAgentStart;
72
+ private onCompact?: OnAgentCompact;
73
+ private maxConcurrent: number;
74
+
75
+ /** Queue of background agents waiting to start. */
76
+ private queue: { id: string; args: SpawnArgs }[] = [];
77
+ /** Number of currently running background agents. */
78
+ private runningBackground = 0;
79
+
80
+ constructor(
81
+ onComplete?: OnAgentComplete,
82
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
83
+ onStart?: OnAgentStart,
84
+ onCompact?: OnAgentCompact,
85
+ ) {
86
+ this.onComplete = onComplete;
87
+ this.onStart = onStart;
88
+ this.onCompact = onCompact;
89
+ this.maxConcurrent = maxConcurrent;
90
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
91
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
92
+ this.cleanupInterval.unref();
93
+ }
94
+
95
+ /** Update the max concurrent background agents limit. */
96
+ setMaxConcurrent(n: number) {
97
+ this.maxConcurrent = Math.max(1, n);
98
+ // Start queued agents if the new limit allows
99
+ this.drainQueue();
100
+ }
101
+
102
+ getMaxConcurrent(): number {
103
+ return this.maxConcurrent;
104
+ }
105
+
106
+ /**
107
+ * Spawn an agent and return its ID immediately (for background use).
108
+ * If the concurrency limit is reached, the agent is queued.
109
+ */
110
+ spawn(
111
+ pi: ExtensionAPI,
112
+ ctx: ExtensionContext,
113
+ type: SubagentType,
114
+ prompt: string,
115
+ options: SpawnOptions,
116
+ ): string {
117
+ const id = randomUUID().slice(0, 17);
118
+ const abortController = new AbortController();
119
+ const record: AgentRecord = {
120
+ id,
121
+ type,
122
+ description: options.description,
123
+ status: options.isBackground ? "queued" : "running",
124
+ toolUses: 0,
125
+ startedAt: Date.now(),
126
+ abortController,
127
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
128
+ compactionCount: 0,
129
+ invocation: options.invocation,
130
+ };
131
+ this.agents.set(id, record);
132
+
133
+ const args: SpawnArgs = { pi, ctx, type, prompt, options };
134
+
135
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
136
+ // Queue it — will be started when a running agent completes
137
+ this.queue.push({ id, args });
138
+ return id;
139
+ }
140
+
141
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
142
+ // up the record so callers don't see an orphan in `listAgents()`.
143
+ try {
144
+ this.startAgent(id, record, args);
145
+ } catch (err) {
146
+ this.agents.delete(id);
147
+ throw err;
148
+ }
149
+ return id;
150
+ }
151
+
152
+ /** Actually start an agent (called immediately or from queue drain). */
153
+ private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
154
+ // Worktree isolation: try to create a temporary git worktree. Strict —
155
+ // fail loud if not possible (no silent fallback to main tree). Done
156
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
157
+ let worktreeCwd: string | undefined;
158
+ if (options.isolation === "worktree") {
159
+ const wt = createWorktree(ctx.cwd, id);
160
+ if (!wt) {
161
+ throw new Error(
162
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
163
+ 'Initialize git and commit at least once, or omit `isolation`.',
164
+ );
165
+ }
166
+ record.worktree = wt;
167
+ worktreeCwd = wt.path;
168
+ }
169
+
170
+ record.status = "running";
171
+ record.startedAt = Date.now();
172
+ if (options.isBackground) this.runningBackground++;
173
+ this.onStart?.(record);
174
+
175
+ // Wire parent abort signal to stop the subagent when the parent is interrupted
176
+ let detachParentSignal: (() => void) | undefined;
177
+ if (options.signal) {
178
+ const onParentAbort = () => this.abort(id);
179
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
180
+ detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
181
+ }
182
+ const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
183
+
184
+ const promise = runAgent(ctx, type, prompt, {
185
+ pi,
186
+ model: options.model,
187
+ maxTurns: options.maxTurns,
188
+ isolated: options.isolated,
189
+ inheritContext: options.inheritContext,
190
+ thinkingLevel: options.thinkingLevel,
191
+ cwd: worktreeCwd,
192
+ signal: record.abortController!.signal,
193
+ onToolActivity: (activity) => {
194
+ if (activity.type === "end") record.toolUses++;
195
+ options.onToolActivity?.(activity);
196
+ },
197
+ onTurnEnd: options.onTurnEnd,
198
+ onTextDelta: options.onTextDelta,
199
+ onAssistantUsage: (usage) => {
200
+ addUsage(record.lifetimeUsage, usage);
201
+ options.onAssistantUsage?.(usage);
202
+ },
203
+ onCompaction: (info) => {
204
+ record.compactionCount++;
205
+ this.onCompact?.(record, info);
206
+ options.onCompaction?.(info);
207
+ },
208
+ onSessionCreated: (session) => {
209
+ record.session = session;
210
+ // Flush any steers that arrived before the session was ready
211
+ if (record.pendingSteers?.length) {
212
+ for (const msg of record.pendingSteers) {
213
+ session.steer(msg).catch(() => {});
214
+ }
215
+ record.pendingSteers = undefined;
216
+ }
217
+ options.onSessionCreated?.(session);
218
+ },
219
+ })
220
+ .then(({ responseText, session, aborted, steered }) => {
221
+ // Don't overwrite status if externally stopped via abort()
222
+ if (record.status !== "stopped") {
223
+ record.status = aborted ? "aborted" : steered ? "steered" : "completed";
224
+ }
225
+ record.result = responseText;
226
+ record.session = session;
227
+ record.completedAt ??= Date.now();
228
+
229
+ detach();
230
+
231
+ // Final flush of streaming output file
232
+ if (record.outputCleanup) {
233
+ try { record.outputCleanup(); } catch { /* ignore */ }
234
+ record.outputCleanup = undefined;
235
+ }
236
+
237
+ // Clean up worktree if used
238
+ if (record.worktree) {
239
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
240
+ record.worktreeResult = wtResult;
241
+ if (wtResult.hasChanges && wtResult.branch) {
242
+ record.result = (record.result ?? "") +
243
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
244
+ }
245
+ }
246
+
247
+ if (options.isBackground) {
248
+ this.runningBackground--;
249
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
250
+ this.drainQueue();
251
+ }
252
+ return responseText;
253
+ })
254
+ .catch((err) => {
255
+ // Don't overwrite status if externally stopped via abort()
256
+ if (record.status !== "stopped") {
257
+ record.status = "error";
258
+ }
259
+ record.error = err instanceof Error ? err.message : String(err);
260
+ record.completedAt ??= Date.now();
261
+
262
+ detach();
263
+
264
+ // Final flush of streaming output file on error
265
+ if (record.outputCleanup) {
266
+ try { record.outputCleanup(); } catch { /* ignore */ }
267
+ record.outputCleanup = undefined;
268
+ }
269
+
270
+ // Best-effort worktree cleanup on error
271
+ if (record.worktree) {
272
+ try {
273
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
274
+ record.worktreeResult = wtResult;
275
+ } catch { /* ignore cleanup errors */ }
276
+ }
277
+
278
+ if (options.isBackground) {
279
+ this.runningBackground--;
280
+ this.onComplete?.(record);
281
+ this.drainQueue();
282
+ }
283
+ return "";
284
+ });
285
+
286
+ record.promise = promise;
287
+ }
288
+
289
+ /** Start queued agents up to the concurrency limit. */
290
+ private drainQueue() {
291
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
292
+ const next = this.queue.shift()!;
293
+ const record = this.agents.get(next.id);
294
+ if (!record || record.status !== "queued") continue;
295
+ try {
296
+ this.startAgent(next.id, record, next.args);
297
+ } catch (err) {
298
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
299
+ // so the user/agent can see it via /agents, then keep draining.
300
+ record.status = "error";
301
+ record.error = err instanceof Error ? err.message : String(err);
302
+ record.completedAt = Date.now();
303
+ this.onComplete?.(record);
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Spawn an agent and wait for completion (foreground use).
310
+ * Foreground agents bypass the concurrency queue.
311
+ */
312
+ async spawnAndWait(
313
+ pi: ExtensionAPI,
314
+ ctx: ExtensionContext,
315
+ type: SubagentType,
316
+ prompt: string,
317
+ options: Omit<SpawnOptions, "isBackground">,
318
+ ): Promise<AgentRecord> {
319
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
320
+ const record = this.agents.get(id)!;
321
+ await record.promise;
322
+ return record;
323
+ }
324
+
325
+ /**
326
+ * Resume an existing agent session with a new prompt.
327
+ */
328
+ async resume(
329
+ id: string,
330
+ prompt: string,
331
+ signal?: AbortSignal,
332
+ ): Promise<AgentRecord | undefined> {
333
+ const record = this.agents.get(id);
334
+ if (!record?.session) return undefined;
335
+
336
+ record.status = "running";
337
+ record.startedAt = Date.now();
338
+ record.completedAt = undefined;
339
+ record.result = undefined;
340
+ record.error = undefined;
341
+
342
+ try {
343
+ const responseText = await resumeAgent(record.session, prompt, {
344
+ onToolActivity: (activity) => {
345
+ if (activity.type === "end") record.toolUses++;
346
+ },
347
+ onAssistantUsage: (usage) => {
348
+ addUsage(record.lifetimeUsage, usage);
349
+ },
350
+ onCompaction: (info) => {
351
+ record.compactionCount++;
352
+ this.onCompact?.(record, info);
353
+ },
354
+ signal,
355
+ });
356
+ record.status = "completed";
357
+ record.result = responseText;
358
+ record.completedAt = Date.now();
359
+ } catch (err) {
360
+ record.status = "error";
361
+ record.error = err instanceof Error ? err.message : String(err);
362
+ record.completedAt = Date.now();
363
+ }
364
+
365
+ return record;
366
+ }
367
+
368
+ getRecord(id: string): AgentRecord | undefined {
369
+ return this.agents.get(id);
370
+ }
371
+
372
+ listAgents(): AgentRecord[] {
373
+ return [...this.agents.values()].sort(
374
+ (a, b) => b.startedAt - a.startedAt,
375
+ );
376
+ }
377
+
378
+ abort(id: string): boolean {
379
+ const record = this.agents.get(id);
380
+ if (!record) return false;
381
+
382
+ // Remove from queue if queued
383
+ if (record.status === "queued") {
384
+ this.queue = this.queue.filter(q => q.id !== id);
385
+ record.status = "stopped";
386
+ record.completedAt = Date.now();
387
+ return true;
388
+ }
389
+
390
+ if (record.status !== "running") return false;
391
+ record.abortController?.abort();
392
+ record.status = "stopped";
393
+ record.completedAt = Date.now();
394
+ return true;
395
+ }
396
+
397
+ /** Dispose a record's session and remove it from the map. */
398
+ private removeRecord(id: string, record: AgentRecord): void {
399
+ record.session?.dispose?.();
400
+ record.session = undefined;
401
+ this.agents.delete(id);
402
+ }
403
+
404
+ private cleanup() {
405
+ const cutoff = Date.now() - 10 * 60_000;
406
+ for (const [id, record] of this.agents) {
407
+ if (record.status === "running" || record.status === "queued") continue;
408
+ if ((record.completedAt ?? 0) >= cutoff) continue;
409
+ this.removeRecord(id, record);
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Remove all completed/stopped/errored records immediately.
415
+ * Called on session start/switch so tasks from a prior session don't persist.
416
+ */
417
+ clearCompleted(): void {
418
+ for (const [id, record] of this.agents) {
419
+ if (record.status === "running" || record.status === "queued") continue;
420
+ this.removeRecord(id, record);
421
+ }
422
+ }
423
+
424
+ /** Whether any agents are still running or queued. */
425
+ hasRunning(): boolean {
426
+ return [...this.agents.values()].some(
427
+ r => r.status === "running" || r.status === "queued",
428
+ );
429
+ }
430
+
431
+ /** Abort all running and queued agents immediately. */
432
+ abortAll(): number {
433
+ let count = 0;
434
+ // Clear queued agents first
435
+ for (const queued of this.queue) {
436
+ const record = this.agents.get(queued.id);
437
+ if (record) {
438
+ record.status = "stopped";
439
+ record.completedAt = Date.now();
440
+ count++;
441
+ }
442
+ }
443
+ this.queue = [];
444
+ // Abort running agents
445
+ for (const record of this.agents.values()) {
446
+ if (record.status === "running") {
447
+ record.abortController?.abort();
448
+ record.status = "stopped";
449
+ record.completedAt = Date.now();
450
+ count++;
451
+ }
452
+ }
453
+ return count;
454
+ }
455
+
456
+ /** Wait for all running and queued agents to complete (including queued ones). */
457
+ async waitForAll(): Promise<void> {
458
+ // Loop because drainQueue respects the concurrency limit — as running
459
+ // agents finish they start queued ones, which need awaiting too.
460
+ while (true) {
461
+ this.drainQueue();
462
+ const pending = [...this.agents.values()]
463
+ .filter(r => r.status === "running" || r.status === "queued")
464
+ .map(r => r.promise)
465
+ .filter(Boolean);
466
+ if (pending.length === 0) break;
467
+ await Promise.allSettled(pending);
468
+ }
469
+ }
470
+
471
+ dispose() {
472
+ clearInterval(this.cleanupInterval);
473
+ // Clear queue
474
+ this.queue = [];
475
+ for (const record of this.agents.values()) {
476
+ record.session?.dispose();
477
+ }
478
+ this.agents.clear();
479
+ // Prune any orphaned git worktrees (crash recovery)
480
+ try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
481
+ }
482
+ }