@clanker-code/pi-subagents 0.10.5

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 (130) hide show
  1. package/.plans/PLAN-next-changes.md +183 -0
  2. package/.plans/README.md +14 -0
  3. package/AGENTS.md +31 -0
  4. package/CHANGELOG.md +583 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +630 -0
  8. package/RELEASE.md +39 -0
  9. package/dist/abort-resend.d.ts +35 -0
  10. package/dist/abort-resend.js +71 -0
  11. package/dist/agent-details.d.ts +17 -0
  12. package/dist/agent-details.js +22 -0
  13. package/dist/agent-manager.d.ts +132 -0
  14. package/dist/agent-manager.js +493 -0
  15. package/dist/agent-runner.d.ts +165 -0
  16. package/dist/agent-runner.js +732 -0
  17. package/dist/agent-tool-description.d.ts +9 -0
  18. package/dist/agent-tool-description.js +147 -0
  19. package/dist/agent-types.d.ts +60 -0
  20. package/dist/agent-types.js +157 -0
  21. package/dist/context.d.ts +12 -0
  22. package/dist/context.js +56 -0
  23. package/dist/cross-extension-rpc.d.ts +46 -0
  24. package/dist/cross-extension-rpc.js +76 -0
  25. package/dist/custom-agents.d.ts +14 -0
  26. package/dist/custom-agents.js +149 -0
  27. package/dist/default-agents.d.ts +7 -0
  28. package/dist/default-agents.js +119 -0
  29. package/dist/enabled-models.d.ts +49 -0
  30. package/dist/enabled-models.js +145 -0
  31. package/dist/env.d.ts +6 -0
  32. package/dist/env.js +28 -0
  33. package/dist/group-join.d.ts +32 -0
  34. package/dist/group-join.js +116 -0
  35. package/dist/index.d.ts +36 -0
  36. package/dist/index.js +1918 -0
  37. package/dist/invocation-config.d.ts +25 -0
  38. package/dist/invocation-config.js +19 -0
  39. package/dist/memory.d.ts +49 -0
  40. package/dist/memory.js +151 -0
  41. package/dist/model-resolver.d.ts +19 -0
  42. package/dist/model-resolver.js +62 -0
  43. package/dist/notifications.d.ts +6 -0
  44. package/dist/notifications.js +107 -0
  45. package/dist/output-file.d.ts +24 -0
  46. package/dist/output-file.js +86 -0
  47. package/dist/peek.d.ts +37 -0
  48. package/dist/peek.js +121 -0
  49. package/dist/prompts.d.ts +40 -0
  50. package/dist/prompts.js +95 -0
  51. package/dist/schedule-store.d.ts +38 -0
  52. package/dist/schedule-store.js +155 -0
  53. package/dist/schedule.d.ts +109 -0
  54. package/dist/schedule.js +338 -0
  55. package/dist/settings.d.ts +135 -0
  56. package/dist/settings.js +168 -0
  57. package/dist/skill-loader.d.ts +24 -0
  58. package/dist/skill-loader.js +93 -0
  59. package/dist/status-note.d.ts +13 -0
  60. package/dist/status-note.js +24 -0
  61. package/dist/types.d.ts +184 -0
  62. package/dist/types.js +7 -0
  63. package/dist/ui/agent-tool-rendering.d.ts +34 -0
  64. package/dist/ui/agent-tool-rendering.js +154 -0
  65. package/dist/ui/agent-widget-tree.d.ts +33 -0
  66. package/dist/ui/agent-widget-tree.js +130 -0
  67. package/dist/ui/agent-widget.d.ts +156 -0
  68. package/dist/ui/agent-widget.js +408 -0
  69. package/dist/ui/conversation-viewer.d.ts +47 -0
  70. package/dist/ui/conversation-viewer.js +290 -0
  71. package/dist/ui/menu-select.d.ts +20 -0
  72. package/dist/ui/menu-select.js +46 -0
  73. package/dist/ui/schedule-menu.d.ts +16 -0
  74. package/dist/ui/schedule-menu.js +99 -0
  75. package/dist/ui/viewer-keys.d.ts +20 -0
  76. package/dist/ui/viewer-keys.js +17 -0
  77. package/dist/usage.d.ts +50 -0
  78. package/dist/usage.js +49 -0
  79. package/dist/wait.d.ts +10 -0
  80. package/dist/wait.js +37 -0
  81. package/dist/worktree.d.ts +45 -0
  82. package/dist/worktree.js +160 -0
  83. package/docs/design/default-extension-tool-exposure.md +56 -0
  84. package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
  85. package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
  86. package/examples/agent-tool-description.md +45 -0
  87. package/package.json +56 -0
  88. package/reviews/proposal-structured-output-schema.md +135 -0
  89. package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
  90. package/reviews/recursive-subagent-widget-preview.html +137 -0
  91. package/reviews/recursive-subagent-widget-preview.png +0 -0
  92. package/reviews/subagent-features-comparison.md +350 -0
  93. package/src/abort-resend.ts +75 -0
  94. package/src/agent-details.ts +31 -0
  95. package/src/agent-manager.ts +596 -0
  96. package/src/agent-runner.ts +872 -0
  97. package/src/agent-tool-description.ts +163 -0
  98. package/src/agent-types.ts +189 -0
  99. package/src/context.ts +58 -0
  100. package/src/cross-extension-rpc.ts +122 -0
  101. package/src/custom-agents.ts +160 -0
  102. package/src/default-agents.ts +123 -0
  103. package/src/enabled-models.ts +180 -0
  104. package/src/env.ts +33 -0
  105. package/src/group-join.ts +141 -0
  106. package/src/index.ts +2115 -0
  107. package/src/invocation-config.ts +42 -0
  108. package/src/memory.ts +165 -0
  109. package/src/model-resolver.ts +81 -0
  110. package/src/notifications.ts +120 -0
  111. package/src/output-file.ts +96 -0
  112. package/src/peek.ts +155 -0
  113. package/src/prompts.ts +129 -0
  114. package/src/schedule-store.ts +153 -0
  115. package/src/schedule.ts +365 -0
  116. package/src/settings.ts +289 -0
  117. package/src/skill-loader.ts +102 -0
  118. package/src/status-note.ts +25 -0
  119. package/src/types.ts +195 -0
  120. package/src/ui/agent-tool-rendering.ts +175 -0
  121. package/src/ui/agent-widget-tree.ts +169 -0
  122. package/src/ui/agent-widget.ts +497 -0
  123. package/src/ui/conversation-viewer.ts +297 -0
  124. package/src/ui/menu-select.ts +68 -0
  125. package/src/ui/schedule-menu.ts +105 -0
  126. package/src/ui/viewer-keys.ts +39 -0
  127. package/src/usage.ts +60 -0
  128. package/src/wait.ts +44 -0
  129. package/src/worktree.ts +191 -0
  130. package/vitest.config.ts +25 -0
@@ -0,0 +1,493 @@
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
+ import { randomUUID } from "node:crypto";
9
+ import { statSync } from "node:fs";
10
+ import { isAbsolute } from "node:path";
11
+ import { resumeAgent, runAgent } from "./agent-runner.js";
12
+ import { MAX_RECURSIVE_DEPTH } from "./types.js";
13
+ import { addUsage } from "./usage.js";
14
+ import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
15
+ /** Default max concurrent background agents. */
16
+ const DEFAULT_MAX_CONCURRENT = 4;
17
+ /**
18
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
19
+ * (parent cwd). Anything else must be an absolute path to an existing
20
+ * directory — curated errors instead of TypeErrors from path/fs internals
21
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
22
+ */
23
+ function assertValidSpawnCwd(cwd) {
24
+ if (cwd == null)
25
+ return;
26
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
27
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
28
+ }
29
+ let isDirectory = false;
30
+ try {
31
+ isDirectory = statSync(cwd).isDirectory();
32
+ }
33
+ catch {
34
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
35
+ }
36
+ if (!isDirectory) {
37
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
38
+ }
39
+ }
40
+ export class AgentManager {
41
+ agents = new Map();
42
+ cleanupInterval;
43
+ onComplete;
44
+ onStart;
45
+ onCompact;
46
+ maxConcurrent;
47
+ /** Base repos worktrees were created from — so dispose() can prune them all,
48
+ * not just the parent repo (caller-supplied cwd can target other repos). */
49
+ worktreeRepos = new Set();
50
+ /** Queue of background agents waiting to start. */
51
+ queue = [];
52
+ /** Number of currently running background agents. */
53
+ runningBackground = 0;
54
+ constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact) {
55
+ this.onComplete = onComplete;
56
+ this.onStart = onStart;
57
+ this.onCompact = onCompact;
58
+ this.maxConcurrent = maxConcurrent;
59
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
60
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
61
+ this.cleanupInterval.unref();
62
+ }
63
+ /** Update the max concurrent background agents limit. */
64
+ setMaxConcurrent(n) {
65
+ this.maxConcurrent = Math.max(1, n);
66
+ // Start queued agents if the new limit allows
67
+ this.drainQueue();
68
+ }
69
+ getMaxConcurrent() {
70
+ return this.maxConcurrent;
71
+ }
72
+ /**
73
+ * Spawn an agent and return its ID immediately (for background use).
74
+ * If the concurrency limit is reached, the agent is queued.
75
+ */
76
+ spawn(pi, ctx, type, prompt, options) {
77
+ // Validate before the queue branch — a queued spawn should fail at the
78
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
79
+ // can fix and retry; the RPC layer converts throws into error envelopes.
80
+ assertValidSpawnCwd(options.cwd);
81
+ const depth = options.depth ?? 1;
82
+ if (depth > MAX_RECURSIVE_DEPTH) {
83
+ throw new Error(`Cannot spawn agent: maximum recursive subagent depth is ${MAX_RECURSIVE_DEPTH}.`);
84
+ }
85
+ const id = randomUUID().slice(0, 17);
86
+ const abortController = new AbortController();
87
+ const record = {
88
+ id,
89
+ type,
90
+ description: options.description,
91
+ status: options.isBackground ? "queued" : "running",
92
+ toolUses: 0,
93
+ startedAt: Date.now(),
94
+ abortController,
95
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
96
+ compactionCount: 0,
97
+ invocation: options.invocation,
98
+ depth,
99
+ parentAgentId: options.parentAgentId,
100
+ };
101
+ this.agents.set(id, record);
102
+ const args = { pi, ctx, type, prompt, options };
103
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
104
+ // Queue it — will be started when a running agent completes
105
+ this.queue.push({ id, args });
106
+ return id;
107
+ }
108
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
109
+ // up the record so callers don't see an orphan in `listAgents()`.
110
+ try {
111
+ this.startAgent(id, record, args);
112
+ }
113
+ catch (err) {
114
+ this.agents.delete(id);
115
+ throw err;
116
+ }
117
+ return id;
118
+ }
119
+ /** Actually start an agent (called immediately or from queue drain). */
120
+ startAgent(id, record, { pi, ctx, type, prompt, options }) {
121
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
122
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
123
+ // curated errors; drainQueue parks a throw on the record as an error.
124
+ assertValidSpawnCwd(options.cwd);
125
+ // Single resolution point for the caller-supplied cwd — the worktree base
126
+ // repo and both cleanup calls below MUST agree on this value forever.
127
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
128
+ const baseCwd = customCwd ?? ctx.cwd;
129
+ // Worktree isolation: try to create a temporary git worktree. Strict —
130
+ // fail loud if not possible (no silent fallback to main tree). Done
131
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
132
+ let worktreeCwd;
133
+ if (options.isolation === "worktree") {
134
+ const wt = createWorktree(baseCwd, id);
135
+ if (!wt) {
136
+ throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
137
+ 'Initialize git and commit at least once, or omit `isolation`.');
138
+ }
139
+ record.worktree = wt;
140
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
141
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
142
+ // the copied repo's root. Plain worktree spawns keep the historical
143
+ // behavior (agent at the copy's root) — moving them to workPath would
144
+ // also move .pi config discovery when the parent session sits in a repo
145
+ // subdirectory, silently dropping extensions/skills.
146
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
147
+ this.worktreeRepos.add(baseCwd);
148
+ }
149
+ record.status = "running";
150
+ record.startedAt = Date.now();
151
+ if (options.isBackground)
152
+ this.runningBackground++;
153
+ this.onStart?.(record);
154
+ // Wire parent abort signal to stop the subagent when the parent is interrupted
155
+ let detachParentSignal;
156
+ if (options.signal) {
157
+ const onParentAbort = () => this.abort(id);
158
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
159
+ detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
160
+ }
161
+ const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
162
+ const promise = runAgent(ctx, type, prompt, {
163
+ pi,
164
+ agentId: id,
165
+ model: options.model,
166
+ maxTurns: options.maxTurns,
167
+ isolated: options.isolated,
168
+ inheritContext: options.inheritContext,
169
+ thinkingLevel: options.thinkingLevel,
170
+ // Worktree wins for the working dir (the agent must run in the copy —
171
+ // which, with a custom cwd, was created from that target). Config stays
172
+ // with the parent project when a caller-supplied cwd is in play; it must
173
+ // stay undefined otherwise so plain worktree runs keep resolving config
174
+ // (incl. relative extension paths and memory) inside the worktree copy.
175
+ cwd: worktreeCwd ?? customCwd,
176
+ configCwd: customCwd !== undefined ? ctx.cwd : undefined,
177
+ signal: record.abortController.signal,
178
+ onToolActivity: (activity) => {
179
+ if (activity.type === "end")
180
+ record.toolUses++;
181
+ options.onToolActivity?.(activity);
182
+ },
183
+ onTurnEnd: options.onTurnEnd,
184
+ onTextDelta: options.onTextDelta,
185
+ onAssistantUsage: (usage) => {
186
+ addUsage(record.lifetimeUsage, usage);
187
+ options.onAssistantUsage?.(usage);
188
+ },
189
+ onCompaction: (info) => {
190
+ record.compactionCount++;
191
+ this.onCompact?.(record, info);
192
+ options.onCompaction?.(info);
193
+ },
194
+ depth: record.depth,
195
+ parentAgentId: record.parentAgentId,
196
+ onSessionCreated: (session) => {
197
+ record.session = session;
198
+ // Flush any steers that arrived before the session was ready
199
+ if (record.pendingSteers?.length) {
200
+ for (const msg of record.pendingSteers) {
201
+ session.steer(msg).catch(() => { });
202
+ }
203
+ record.pendingSteers = undefined;
204
+ }
205
+ options.onSessionCreated?.(session);
206
+ },
207
+ })
208
+ .then(({ responseText, session, aborted, steered }) => {
209
+ // Don't overwrite status if externally stopped via abort()
210
+ if (record.status !== "stopped") {
211
+ record.status = aborted ? "aborted" : steered ? "steered" : "completed";
212
+ }
213
+ record.result = responseText;
214
+ record.session = session;
215
+ record.completedAt ??= Date.now();
216
+ detach();
217
+ // Final flush of streaming output file
218
+ if (record.outputCleanup) {
219
+ try {
220
+ record.outputCleanup();
221
+ }
222
+ catch { /* ignore */ }
223
+ record.outputCleanup = undefined;
224
+ }
225
+ // Clean up worktree if used
226
+ if (record.worktree) {
227
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
228
+ record.worktreeResult = wtResult;
229
+ if (wtResult.hasChanges && wtResult.branch) {
230
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
231
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
232
+ const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
233
+ record.result = (record.result ?? "") +
234
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
235
+ }
236
+ }
237
+ if (options.isBackground) {
238
+ this.runningBackground--;
239
+ try {
240
+ this.onComplete?.(record);
241
+ }
242
+ catch { /* ignore completion side-effect errors */ }
243
+ this.drainQueue();
244
+ }
245
+ return responseText;
246
+ })
247
+ .catch((err) => {
248
+ // Don't overwrite status if externally stopped via abort()
249
+ if (record.status !== "stopped") {
250
+ record.status = "error";
251
+ }
252
+ record.error = err instanceof Error ? err.message : String(err);
253
+ record.completedAt ??= Date.now();
254
+ detach();
255
+ // Final flush of streaming output file on error
256
+ if (record.outputCleanup) {
257
+ try {
258
+ record.outputCleanup();
259
+ }
260
+ catch { /* ignore */ }
261
+ record.outputCleanup = undefined;
262
+ }
263
+ // Best-effort worktree cleanup on error
264
+ if (record.worktree) {
265
+ try {
266
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
267
+ record.worktreeResult = wtResult;
268
+ }
269
+ catch { /* ignore cleanup errors */ }
270
+ }
271
+ if (options.isBackground) {
272
+ this.runningBackground--;
273
+ this.onComplete?.(record);
274
+ this.drainQueue();
275
+ }
276
+ return "";
277
+ });
278
+ record.promise = promise;
279
+ }
280
+ /** Start queued agents up to the concurrency limit. */
281
+ drainQueue() {
282
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
283
+ const next = this.queue.shift();
284
+ const record = this.agents.get(next.id);
285
+ if (!record || record.status !== "queued")
286
+ continue;
287
+ try {
288
+ this.startAgent(next.id, record, next.args);
289
+ }
290
+ catch (err) {
291
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
292
+ // so the user/agent can see it via /agents, then keep draining.
293
+ record.status = "error";
294
+ record.error = err instanceof Error ? err.message : String(err);
295
+ record.completedAt = Date.now();
296
+ this.onComplete?.(record);
297
+ }
298
+ }
299
+ }
300
+ /**
301
+ * Spawn an agent and wait for completion (foreground use).
302
+ * Foreground agents bypass the concurrency queue.
303
+ */
304
+ async spawnAndWait(pi, ctx, type, prompt, options) {
305
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
306
+ const record = this.agents.get(id);
307
+ await record.promise;
308
+ return record;
309
+ }
310
+ /** Resume an existing agent session in the background. */
311
+ resume(id, prompt, signalOrOptions) {
312
+ const record = this.agents.get(id);
313
+ if (!record?.session)
314
+ return undefined;
315
+ const options = typeof signalOrOptions?.addEventListener === "function"
316
+ ? { signal: signalOrOptions }
317
+ : (signalOrOptions ?? {});
318
+ record.status = "running";
319
+ record.startedAt = Date.now();
320
+ record.completedAt = undefined;
321
+ record.result = undefined;
322
+ record.error = undefined;
323
+ record.resultConsumed = false;
324
+ record.abortController = new AbortController();
325
+ this.runningBackground++;
326
+ this.onStart?.(record);
327
+ const onParentAbort = () => this.abort(id);
328
+ options.signal?.addEventListener("abort", onParentAbort, { once: true });
329
+ const detach = () => options.signal?.removeEventListener("abort", onParentAbort);
330
+ const promise = resumeAgent(record.session, prompt, {
331
+ onToolActivity: (activity) => {
332
+ if (activity.type === "end")
333
+ record.toolUses++;
334
+ options.onToolActivity?.(activity);
335
+ },
336
+ onAssistantUsage: (usage) => {
337
+ addUsage(record.lifetimeUsage, usage);
338
+ options.onAssistantUsage?.(usage);
339
+ },
340
+ onCompaction: (info) => {
341
+ record.compactionCount++;
342
+ this.onCompact?.(record, info);
343
+ options.onCompaction?.(info);
344
+ },
345
+ signal: record.abortController.signal,
346
+ }).then((responseText) => {
347
+ if (record.status !== "stopped") {
348
+ record.status = "completed";
349
+ }
350
+ record.result = responseText;
351
+ record.completedAt = Date.now();
352
+ detach();
353
+ this.runningBackground--;
354
+ try {
355
+ this.onComplete?.(record);
356
+ }
357
+ catch { /* ignore completion side-effect errors */ }
358
+ this.drainQueue();
359
+ return responseText;
360
+ }).catch((err) => {
361
+ if (record.status !== "stopped")
362
+ record.status = "error";
363
+ record.error = err instanceof Error ? err.message : String(err);
364
+ record.completedAt = Date.now();
365
+ detach();
366
+ this.runningBackground--;
367
+ try {
368
+ this.onComplete?.(record);
369
+ }
370
+ catch { /* ignore completion side-effect errors */ }
371
+ this.drainQueue();
372
+ return "";
373
+ });
374
+ record.promise = promise;
375
+ return record;
376
+ }
377
+ getRecord(id) {
378
+ return this.agents.get(id);
379
+ }
380
+ listAgents() {
381
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
382
+ }
383
+ abort(id) {
384
+ const record = this.agents.get(id);
385
+ if (!record)
386
+ return false;
387
+ // Remove from queue if queued
388
+ if (record.status === "queued") {
389
+ this.queue = this.queue.filter(q => q.id !== id);
390
+ record.status = "stopped";
391
+ record.completedAt = Date.now();
392
+ return true;
393
+ }
394
+ if (record.status !== "running")
395
+ return false;
396
+ record.abortController?.abort();
397
+ record.status = "stopped";
398
+ record.completedAt = Date.now();
399
+ return true;
400
+ }
401
+ /** Dispose a record's session and remove it from the map. */
402
+ removeRecord(id, record) {
403
+ record.session?.dispose?.();
404
+ record.session = undefined;
405
+ this.agents.delete(id);
406
+ }
407
+ cleanup() {
408
+ const cutoff = Date.now() - 10 * 60_000;
409
+ for (const [id, record] of this.agents) {
410
+ if (record.status === "running" || record.status === "queued")
411
+ continue;
412
+ if ((record.completedAt ?? 0) >= cutoff)
413
+ continue;
414
+ this.removeRecord(id, record);
415
+ }
416
+ }
417
+ /**
418
+ * Remove all completed/stopped/errored records immediately.
419
+ * Called on session start/switch so tasks from a prior session don't persist.
420
+ */
421
+ clearCompleted() {
422
+ for (const [id, record] of this.agents) {
423
+ if (record.status === "running" || record.status === "queued")
424
+ continue;
425
+ this.removeRecord(id, record);
426
+ }
427
+ }
428
+ /** Whether any agents are still running or queued. */
429
+ hasRunning() {
430
+ return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
431
+ }
432
+ /** Abort all running and queued agents immediately. */
433
+ abortAll() {
434
+ let count = 0;
435
+ // Clear queued agents first
436
+ for (const queued of this.queue) {
437
+ const record = this.agents.get(queued.id);
438
+ if (record) {
439
+ record.status = "stopped";
440
+ record.completedAt = Date.now();
441
+ count++;
442
+ }
443
+ }
444
+ this.queue = [];
445
+ // Abort running agents
446
+ for (const record of this.agents.values()) {
447
+ if (record.status === "running") {
448
+ record.abortController?.abort();
449
+ record.status = "stopped";
450
+ record.completedAt = Date.now();
451
+ count++;
452
+ }
453
+ }
454
+ return count;
455
+ }
456
+ /** Wait for all running and queued agents to complete (including queued ones). */
457
+ async waitForAll() {
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)
467
+ break;
468
+ await Promise.allSettled(pending);
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 {
481
+ pruneWorktrees(process.cwd());
482
+ }
483
+ catch { /* ignore */ }
484
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
485
+ // exit with in-flight agents would otherwise leave stale registrations there.
486
+ for (const repo of this.worktreeRepos) {
487
+ try {
488
+ pruneWorktrees(repo);
489
+ }
490
+ catch { /* ignore */ }
491
+ }
492
+ }
493
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
+ */
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { type AgentSession, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { type SubagentType, type ThinkingLevel } from "./types.js";
8
+ /**
9
+ * Tool names registered by THIS extension. Single source of truth so the
10
+ * registration sites (index.ts) and the subagent exclusion list below can't
11
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
12
+ * derived from pi — but they only need defining once.
13
+ */
14
+ export declare const SUBAGENT_TOOL_NAMES: {
15
+ readonly AGENT: "Agent";
16
+ readonly GET_RESULT: "get_subagent_result";
17
+ readonly STEER: "steer_subagent";
18
+ readonly LIST_MODELS: "list_models";
19
+ };
20
+ export declare function getCurrentExtensionDepth(): number;
21
+ export declare function getCurrentExtensionAgentId(): string | undefined;
22
+ /**
23
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
24
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
25
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
26
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
27
+ * single-file extensions to the basename minus `.ts`/`.js`.
28
+ */
29
+ export declare function extensionCanonicalName(extPath: string): string;
30
+ /**
31
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
32
+ *
33
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
34
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
35
+ *
36
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
37
+ * — and their canonical name is also added to `names`. The loader override matches
38
+ * everything by canonical name, so path-loaded extensions are matched via their name
39
+ * rather than their post-staging `Extension.path`.
40
+ */
41
+ export declare function parseExtensionsSpec(entries: string[], cwd: string): {
42
+ names: Set<string>;
43
+ paths: string[];
44
+ wildcard: boolean;
45
+ };
46
+ /**
47
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
48
+ * extension names to keep loaded and a per-extension tool-narrowing map.
49
+ *
50
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
51
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
52
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
53
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
54
+ * The split is on the first `/`; extension canonical names never contain `/`.
55
+ */
56
+ export declare function parseExtSelectors(entries: string[]): {
57
+ extNames: Set<string>;
58
+ narrowing: Map<string, Set<string>>;
59
+ };
60
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
61
+ export declare function normalizeMaxTurns(n: number | undefined): number | undefined;
62
+ /** Get the default max turns value. undefined = unlimited. */
63
+ export declare function getDefaultMaxTurns(): number | undefined;
64
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
65
+ export declare function setDefaultMaxTurns(n: number | undefined): void;
66
+ /** Get the grace turns value. */
67
+ export declare function getGraceTurns(): number;
68
+ /** Set the grace turns value (minimum 1). */
69
+ export declare function setGraceTurns(n: number): void;
70
+ /** Info about a tool event in the subagent. */
71
+ export interface ToolActivity {
72
+ type: "start" | "end";
73
+ toolName: string;
74
+ }
75
+ export interface RunOptions {
76
+ /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
77
+ pi: ExtensionAPI;
78
+ /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
79
+ agentId?: string;
80
+ model?: Model<any>;
81
+ maxTurns?: number;
82
+ signal?: AbortSignal;
83
+ isolated?: boolean;
84
+ inheritContext?: boolean;
85
+ thinkingLevel?: ThinkingLevel;
86
+ /** Override working directory (e.g. for worktree isolation). */
87
+ cwd?: string;
88
+ /**
89
+ * Where .pi config is discovered (project extensions, skills, pi settings,
90
+ * agent memory). Default: same as the working directory. The manager sets
91
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
92
+ * working directory elsewhere — the agent works *there* but carries the
93
+ * parent project's config (the target's `.pi` extensions never execute).
94
+ *
95
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
96
+ * user didn't open, you almost certainly must pass `configCwd` too —
97
+ * omitting it makes the target's `.pi` extensions execute in this process.
98
+ * (Worktree isolation is the one intentional exception: its copy IS the
99
+ * parent's repo, so config resolving inside it is correct.)
100
+ */
101
+ configCwd?: string;
102
+ /** Called on tool start/end with activity info. */
103
+ onToolActivity?: (activity: ToolActivity) => void;
104
+ /** Called on streaming text deltas from the assistant response. */
105
+ onTextDelta?: (delta: string, fullText: string) => void;
106
+ onSessionCreated?: (session: AgentSession) => void;
107
+ /** Called at the end of each agentic turn with the cumulative count. */
108
+ onTurnEnd?: (turnCount: number) => void;
109
+ /**
110
+ * Called once per assistant message_end with that message's usage delta.
111
+ * Lets callers maintain a lifetime accumulator that survives compaction
112
+ * (which replaces session.state.messages and resets stats-derived sums).
113
+ */
114
+ onAssistantUsage?: (usage: {
115
+ input: number;
116
+ output: number;
117
+ cacheWrite: number;
118
+ }) => void;
119
+ /**
120
+ * Called when the session successfully compacts. `tokensBefore` is upstream's
121
+ * pre-compaction context size estimate. Aborted compactions don't fire.
122
+ */
123
+ onCompaction?: (info: {
124
+ reason: "manual" | "threshold" | "overflow";
125
+ tokensBefore: number;
126
+ }) => void;
127
+ /** Recursive subagent depth. Parent/orchestrator is 0; spawned agents are 1..4. */
128
+ depth?: number;
129
+ /** Parent subagent id when spawned recursively from another subagent. */
130
+ parentAgentId?: string;
131
+ }
132
+ export interface RunResult {
133
+ responseText: string;
134
+ session: AgentSession;
135
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
136
+ aborted: boolean;
137
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
138
+ steered: boolean;
139
+ }
140
+ export declare function runAgent(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
141
+ /**
142
+ * Send a new prompt to an existing session (resume).
143
+ */
144
+ export declare function resumeAgent(session: AgentSession, prompt: string, options?: {
145
+ onToolActivity?: (activity: ToolActivity) => void;
146
+ onAssistantUsage?: (usage: {
147
+ input: number;
148
+ output: number;
149
+ cacheWrite: number;
150
+ }) => void;
151
+ onCompaction?: (info: {
152
+ reason: "manual" | "threshold" | "overflow";
153
+ tokensBefore: number;
154
+ }) => void;
155
+ signal?: AbortSignal;
156
+ }): Promise<string>;
157
+ /**
158
+ * Send a steering message to a running subagent.
159
+ * The message will interrupt the agent after its current tool execution.
160
+ */
161
+ export declare function steerAgent(session: AgentSession, message: string): Promise<void>;
162
+ /**
163
+ * Get the subagent's conversation messages as formatted text.
164
+ */
165
+ export declare function getAgentConversation(session: AgentSession): string;