@ilya-lesikov/pi-pi 0.8.0 → 0.10.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 (114) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +29 -8
  30. package/extensions/orchestrator/agents/constraints.test.ts +65 -1
  31. package/extensions/orchestrator/agents/constraints.ts +73 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -69
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  46. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  47. package/extensions/orchestrator/ast-search.test.ts +124 -0
  48. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  49. package/extensions/orchestrator/billing-spoof.ts +95 -0
  50. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  51. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  52. package/extensions/orchestrator/command-handlers.ts +49 -27
  53. package/extensions/orchestrator/commands.test.ts +15 -2
  54. package/extensions/orchestrator/commands.ts +1 -1
  55. package/extensions/orchestrator/config.test.ts +129 -2
  56. package/extensions/orchestrator/config.ts +115 -19
  57. package/extensions/orchestrator/context.test.ts +46 -0
  58. package/extensions/orchestrator/context.ts +34 -5
  59. package/extensions/orchestrator/custom-footer.test.ts +105 -0
  60. package/extensions/orchestrator/custom-footer.ts +22 -20
  61. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  62. package/extensions/orchestrator/doctor.ts +6 -2
  63. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  64. package/extensions/orchestrator/event-handlers.test.ts +411 -10
  65. package/extensions/orchestrator/event-handlers.ts +738 -349
  66. package/extensions/orchestrator/exa.more.test.ts +118 -0
  67. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  68. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  69. package/extensions/orchestrator/flant-infra.ts +113 -39
  70. package/extensions/orchestrator/index.test.ts +76 -0
  71. package/extensions/orchestrator/index.ts +2 -0
  72. package/extensions/orchestrator/integration.test.ts +488 -74
  73. package/extensions/orchestrator/model-registry.test.ts +2 -1
  74. package/extensions/orchestrator/model-registry.ts +12 -2
  75. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  76. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  77. package/extensions/orchestrator/orchestrator.ts +165 -85
  78. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  79. package/extensions/orchestrator/phases/brainstorm.ts +53 -12
  80. package/extensions/orchestrator/phases/implementation.ts +2 -0
  81. package/extensions/orchestrator/phases/machine.test.ts +53 -1
  82. package/extensions/orchestrator/phases/machine.ts +15 -2
  83. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  84. package/extensions/orchestrator/phases/planning.ts +18 -3
  85. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  86. package/extensions/orchestrator/phases/review-task.ts +47 -14
  87. package/extensions/orchestrator/phases/review.test.ts +36 -0
  88. package/extensions/orchestrator/phases/review.ts +62 -10
  89. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  90. package/extensions/orchestrator/phases/verdict.ts +6 -5
  91. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  92. package/extensions/orchestrator/plannotator.test.ts +38 -1
  93. package/extensions/orchestrator/plannotator.ts +50 -3
  94. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  95. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  96. package/extensions/orchestrator/pp-menu.test.ts +314 -1
  97. package/extensions/orchestrator/pp-menu.ts +937 -450
  98. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  99. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  100. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  101. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  102. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  103. package/extensions/orchestrator/review-files.test.ts +26 -0
  104. package/extensions/orchestrator/review-files.ts +3 -0
  105. package/extensions/orchestrator/state.test.ts +82 -1
  106. package/extensions/orchestrator/state.ts +110 -23
  107. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  108. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  109. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  110. package/extensions/orchestrator/transition-controller.ts +61 -3
  111. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  112. package/package.json +6 -2
  113. package/scripts/test-3p.sh +52 -0
  114. package/AGENTS.md +0 -28
@@ -7,18 +7,45 @@
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
+ import { statSync } from "node:fs";
11
+ import { isAbsolute } from "node:path";
10
12
  import type { Model } from "@earendil-works/pi-ai";
11
13
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
14
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
- import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
15
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
16
+ import { addUsage } from "./usage.js";
14
17
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
15
18
 
16
19
  export type OnAgentComplete = (record: AgentRecord) => void;
17
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 };
18
23
 
19
24
  /** Default max concurrent background agents. */
20
25
  const DEFAULT_MAX_CONCURRENT = 4;
21
26
 
27
+ /**
28
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
29
+ * (parent cwd). Anything else must be an absolute path to an existing
30
+ * directory — curated errors instead of TypeErrors from path/fs internals
31
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
32
+ */
33
+ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
34
+ if (cwd == null) return;
35
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
36
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
37
+ }
38
+ let isDirectory = false;
39
+ try {
40
+ isDirectory = statSync(cwd).isDirectory();
41
+ } catch {
42
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
43
+ }
44
+ if (!isDirectory) {
45
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
46
+ }
47
+ }
48
+
22
49
  interface SpawnArgs {
23
50
  pi: ExtensionAPI;
24
51
  ctx: ExtensionContext;
@@ -35,8 +62,27 @@ interface SpawnOptions {
35
62
  inheritContext?: boolean;
36
63
  thinkingLevel?: ThinkingLevel;
37
64
  isBackground?: boolean;
65
+ /**
66
+ * Skip the maxConcurrent queue check for this spawn — start immediately even
67
+ * if the configured concurrency limit would otherwise queue it. Used by the
68
+ * scheduler so a fired job can't be deferred past its trigger window.
69
+ */
70
+ bypassQueue?: boolean;
38
71
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
39
72
  isolation?: IsolationMode;
73
+ /**
74
+ * Working directory for the agent (absolute path). Default: parent session
75
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
76
+ * settings, memory) still loads from the parent session's project — the
77
+ * target directory's `.pi` extensions never execute. With isolation:
78
+ * "worktree", the worktree is created FROM this directory and the result
79
+ * branch lands in that repo.
80
+ */
81
+ cwd?: string;
82
+ /** Resolved invocation snapshot captured for UI display. */
83
+ invocation?: AgentInvocation;
84
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
85
+ signal?: AbortSignal;
40
86
  /** Called on tool start/end with activity info (for streaming progress to UI). */
41
87
  onToolActivity?: (activity: ToolActivity) => void;
42
88
  /** Called on streaming text deltas from the assistant response. */
@@ -45,10 +91,10 @@ interface SpawnOptions {
45
91
  onSessionCreated?: (session: AgentSession) => void;
46
92
  /** Called at the end of each agentic turn with the cumulative count. */
47
93
  onTurnEnd?: (turnCount: number) => void;
48
- validateCompletion?: () => string | undefined;
49
- maxValidationRetries?: number;
50
- /** toolCallId of the parent tool call that spawned this subagent (for trace correlation). */
51
- toolCallId?: string;
94
+ /** Called once per assistant message_end with that message's usage delta. */
95
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
96
+ /** Called when the session successfully compacts. */
97
+ onCompaction?: (info: CompactionInfo) => void;
52
98
  }
53
99
 
54
100
  export class AgentManager {
@@ -56,19 +102,30 @@ export class AgentManager {
56
102
  private cleanupInterval: ReturnType<typeof setInterval>;
57
103
  private onComplete?: OnAgentComplete;
58
104
  private onStart?: OnAgentStart;
105
+ private onCompact?: OnAgentCompact;
59
106
  private maxConcurrent: number;
107
+ /** Base repos worktrees were created from — so dispose() can prune them all,
108
+ * not just the parent repo (caller-supplied cwd can target other repos). */
109
+ private worktreeRepos = new Set<string>();
60
110
 
61
111
  /** Queue of background agents waiting to start. */
62
112
  private queue: { id: string; args: SpawnArgs }[] = [];
63
113
  /** Number of currently running background agents. */
64
114
  private runningBackground = 0;
65
115
 
66
- constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
116
+ constructor(
117
+ onComplete?: OnAgentComplete,
118
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
119
+ onStart?: OnAgentStart,
120
+ onCompact?: OnAgentCompact,
121
+ ) {
67
122
  this.onComplete = onComplete;
68
123
  this.onStart = onStart;
124
+ this.onCompact = onCompact;
69
125
  this.maxConcurrent = maxConcurrent;
70
126
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
71
127
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
128
+ this.cleanupInterval.unref();
72
129
  }
73
130
 
74
131
  /** Update the max concurrent background agents limit. */
@@ -93,6 +150,11 @@ export class AgentManager {
93
150
  prompt: string,
94
151
  options: SpawnOptions,
95
152
  ): string {
153
+ // Validate before the queue branch — a queued spawn should fail at the
154
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
155
+ // can fix and retry; the RPC layer converts throws into error envelopes.
156
+ assertValidSpawnCwd(options.cwd);
157
+
96
158
  const id = randomUUID().slice(0, 17);
97
159
  const abortController = new AbortController();
98
160
  const record: AgentRecord = {
@@ -103,99 +165,116 @@ export class AgentManager {
103
165
  toolUses: 0,
104
166
  startedAt: Date.now(),
105
167
  abortController,
168
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
169
+ compactionCount: 0,
170
+ // Raw tri-state (not coerced to a boolean): true = background, false =
171
+ // foreground (has an inline tool-result surface), undefined = caller never
172
+ // declared it (e.g. a cross-extension RPC spawn). The widget's background-
173
+ // only filter excludes only explicit `false`, so undefined agents — which
174
+ // have no inline surface — stay visible instead of vanishing.
175
+ isBackground: options.isBackground,
176
+ invocation: options.invocation,
106
177
  };
107
178
  this.agents.set(id, record);
108
179
 
109
180
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
110
181
 
111
- if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
182
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
112
183
  // Queue it — will be started when a running agent completes
113
184
  this.queue.push({ id, args });
114
185
  return id;
115
186
  }
116
187
 
117
- this.startAgent(id, record, args);
188
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
189
+ // up the record so callers don't see an orphan in `listAgents()`.
190
+ try {
191
+ this.startAgent(id, record, args);
192
+ } catch (err) {
193
+ this.agents.delete(id);
194
+ throw err;
195
+ }
118
196
  return id;
119
197
  }
120
198
 
121
199
  /** Actually start an agent (called immediately or from queue drain). */
122
200
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
201
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
202
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
203
+ // curated errors; drainQueue parks a throw on the record as an error.
204
+ assertValidSpawnCwd(options.cwd);
205
+ // Single resolution point for the caller-supplied cwd — the worktree base
206
+ // repo and both cleanup calls below MUST agree on this value forever.
207
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
208
+ const baseCwd = customCwd ?? ctx.cwd;
209
+
210
+ // Worktree isolation: try to create a temporary git worktree. Strict —
211
+ // fail loud if not possible (no silent fallback to main tree). Done
212
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
213
+ let worktreeCwd: string | undefined;
214
+ if (options.isolation === "worktree") {
215
+ const wt = createWorktree(baseCwd, id);
216
+ if (!wt) {
217
+ throw new Error(
218
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
219
+ 'Initialize git and commit at least once, or omit `isolation`.',
220
+ );
221
+ }
222
+ record.worktree = wt;
223
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
224
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
225
+ // the copied repo's root. Plain worktree spawns keep the historical
226
+ // behavior (agent at the copy's root) — moving them to workPath would
227
+ // also move .pi config discovery when the parent session sits in a repo
228
+ // subdirectory, silently dropping extensions/skills.
229
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
230
+ this.worktreeRepos.add(baseCwd);
231
+ }
232
+
123
233
  record.status = "running";
124
234
  record.startedAt = Date.now();
125
- if (options.toolCallId && !record.toolCallId) record.toolCallId = options.toolCallId;
126
- // Reset first-progress flags: a re-started (e.g. dequeued) record must be able
127
- // to emit first_tool/first_turn again for its fresh run.
128
- record.firstToolEmitted = false;
129
- record.firstTurnEmitted = false;
130
235
  if (options.isBackground) this.runningBackground++;
131
236
  this.onStart?.(record);
132
237
 
133
- // Worktree isolation: create a temporary git worktree if requested
134
- let worktreeCwd: string | undefined;
135
- let worktreeWarning = "";
136
- if (options.isolation === "worktree") {
137
- const wt = createWorktree(ctx.cwd, id);
138
- if (wt) {
139
- record.worktree = wt;
140
- worktreeCwd = wt.path;
141
- } else {
142
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
143
- }
238
+ // Wire parent abort signal to stop the subagent when the parent is interrupted
239
+ let detachParentSignal: (() => void) | undefined;
240
+ if (options.signal) {
241
+ const onParentAbort = () => this.abort(id);
242
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
243
+ detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
144
244
  }
245
+ const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
145
246
 
146
- // Prepend worktree warning to prompt if isolation failed
147
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
148
-
149
- const promise = runAgent(ctx, type, effectivePrompt, {
247
+ const promise = runAgent(ctx, type, prompt, {
150
248
  pi,
249
+ agentId: id,
151
250
  model: options.model,
152
251
  maxTurns: options.maxTurns,
153
252
  isolated: options.isolated,
154
253
  inheritContext: options.inheritContext,
155
254
  thinkingLevel: options.thinkingLevel,
156
- cwd: worktreeCwd,
255
+ // Worktree wins for the working dir (the agent must run in the copy —
256
+ // which, with a custom cwd, was created from that target). Config stays
257
+ // with the parent project when a caller-supplied cwd is in play; it must
258
+ // stay undefined otherwise so plain worktree runs keep resolving config
259
+ // (incl. relative extension paths and memory) inside the worktree copy.
260
+ cwd: worktreeCwd ?? customCwd,
261
+ configCwd: customCwd !== undefined ? ctx.cwd : undefined,
157
262
  signal: record.abortController!.signal,
158
263
  onToolActivity: (activity) => {
159
264
  if (activity.type === "end") record.toolUses++;
160
- // Emit first_tool once per run from this single choke point, so ALL spawn
161
- // paths (RPC panels, Agent-tool background, foreground) report it — not
162
- // just the Agent tool's background branch. Panels spawn via RPC and were
163
- // previously silent, making it impossible to tell if a reviewer actually
164
- // investigated (ran tools) or wrote from context alone.
165
- if (activity.type === "start" && !record.firstToolEmitted) {
166
- record.firstToolEmitted = true;
167
- try {
168
- pi.events.emit("subagents:first_tool", {
169
- id,
170
- type,
171
- description: options.description,
172
- toolName: activity.toolName,
173
- });
174
- } catch { /* ignore */ }
175
- }
176
265
  options.onToolActivity?.(activity);
177
266
  },
178
- onTurnEnd: (turnCount) => {
179
- if (!record.firstTurnEmitted) {
180
- record.firstTurnEmitted = true;
181
- try {
182
- pi.events.emit("subagents:first_turn", {
183
- id,
184
- type,
185
- description: options.description,
186
- turnCount,
187
- });
188
- } catch { /* ignore */ }
189
- }
190
- options.onTurnEnd?.(turnCount);
191
- },
267
+ onTurnEnd: options.onTurnEnd,
192
268
  onTextDelta: options.onTextDelta,
193
- validateCompletion: options.validateCompletion,
194
- maxValidationRetries: options.maxValidationRetries,
195
- subagentId: id,
196
- subagentType: type,
197
- subagentDescription: options.description,
198
- parentToolCallId: options.toolCallId ?? record.toolCallId,
269
+ onAssistantUsage: (usage) => {
270
+ addUsage(record.lifetimeUsage, usage);
271
+ options.onAssistantUsage?.(usage);
272
+ },
273
+ onCompaction: (info) => {
274
+ record.compactionCount++;
275
+ this.onCompact?.(record, info);
276
+ options.onCompaction?.(info);
277
+ },
199
278
  onSessionCreated: (session) => {
200
279
  record.session = session;
201
280
  // Flush any steers that arrived before the session was ready
@@ -217,6 +296,8 @@ export class AgentManager {
217
296
  record.session = session;
218
297
  record.completedAt ??= Date.now();
219
298
 
299
+ detach();
300
+
220
301
  // Final flush of streaming output file
221
302
  if (record.outputCleanup) {
222
303
  try { record.outputCleanup(); } catch { /* ignore */ }
@@ -225,17 +306,25 @@ export class AgentManager {
225
306
 
226
307
  // Clean up worktree if used
227
308
  if (record.worktree) {
228
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
309
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
229
310
  record.worktreeResult = wtResult;
230
311
  if (wtResult.hasChanges && wtResult.branch) {
312
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
313
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
314
+ const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
231
315
  record.result = (record.result ?? "") +
232
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
316
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
233
317
  }
234
318
  }
235
319
 
236
- if (options.isBackground) {
320
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
321
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
322
+ if (!options.isBackground) {
323
+ record.resultConsumed = true;
324
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
325
+ } else {
237
326
  this.runningBackground--;
238
- this.onComplete?.(record);
327
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
239
328
  this.drainQueue();
240
329
  }
241
330
  return responseText;
@@ -248,6 +337,8 @@ export class AgentManager {
248
337
  record.error = err instanceof Error ? err.message : String(err);
249
338
  record.completedAt ??= Date.now();
250
339
 
340
+ detach();
341
+
251
342
  // Final flush of streaming output file on error
252
343
  if (record.outputCleanup) {
253
344
  try { record.outputCleanup(); } catch { /* ignore */ }
@@ -257,12 +348,17 @@ export class AgentManager {
257
348
  // Best-effort worktree cleanup on error
258
349
  if (record.worktree) {
259
350
  try {
260
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
351
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
261
352
  record.worktreeResult = wtResult;
262
353
  } catch { /* ignore cleanup errors */ }
263
354
  }
264
355
 
265
- if (options.isBackground) {
356
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
357
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
358
+ if (!options.isBackground) {
359
+ record.resultConsumed = true;
360
+ this.onComplete?.(record);
361
+ } else {
266
362
  this.runningBackground--;
267
363
  this.onComplete?.(record);
268
364
  this.drainQueue();
@@ -271,6 +367,11 @@ export class AgentManager {
271
367
  });
272
368
 
273
369
  record.promise = promise;
370
+
371
+ // Notify caller that spawn is complete (record is in the map, promise is set).
372
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
373
+ // Used by spawnAndWait to let the caller set up output files before streaming starts.
374
+ this.onSpawned?.(id);
274
375
  }
275
376
 
276
377
  /** Start queued agents up to the concurrency limit. */
@@ -279,13 +380,33 @@ export class AgentManager {
279
380
  const next = this.queue.shift()!;
280
381
  const record = this.agents.get(next.id);
281
382
  if (!record || record.status !== "queued") continue;
282
- this.startAgent(next.id, record, next.args);
383
+ try {
384
+ this.startAgent(next.id, record, next.args);
385
+ } catch (err) {
386
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
387
+ // so the user/agent can see it via /agents, then keep draining.
388
+ record.status = "error";
389
+ record.error = err instanceof Error ? err.message : String(err);
390
+ record.completedAt = Date.now();
391
+ this.onComplete?.(record);
392
+ }
283
393
  }
284
394
  }
285
395
 
396
+ /**
397
+ * Called synchronously right after spawn, before onSessionCreated fires.
398
+ * Lets the caller set up the output file path on the record.
399
+ * The record is guaranteed to be in this.agents at this point.
400
+ */
401
+ private onSpawned?: (id: string) => void;
402
+
286
403
  /**
287
404
  * Spawn an agent and wait for completion (foreground use).
288
405
  * Foreground agents bypass the concurrency queue.
406
+ * Returns { id, record } so callers can access the agent ID.
407
+ *
408
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
409
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
289
410
  */
290
411
  async spawnAndWait(
291
412
  pi: ExtensionAPI,
@@ -293,11 +414,19 @@ export class AgentManager {
293
414
  type: SubagentType,
294
415
  prompt: string,
295
416
  options: Omit<SpawnOptions, "isBackground">,
296
- ): Promise<AgentRecord> {
297
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
298
- const record = this.agents.get(id)!;
299
- await record.promise;
300
- return record;
417
+ onSpawned?: (id: string) => void,
418
+ ): Promise<{ id: string; record: AgentRecord }> {
419
+ // Temporarily register the onSpawned hook so startAgent can call it.
420
+ const prevOnSpawned = this.onSpawned;
421
+ this.onSpawned = onSpawned;
422
+ try {
423
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
424
+ const record = this.agents.get(id)!;
425
+ await record.promise;
426
+ return { id, record };
427
+ } finally {
428
+ this.onSpawned = prevOnSpawned;
429
+ }
301
430
  }
302
431
 
303
432
  /**
@@ -322,8 +451,14 @@ export class AgentManager {
322
451
  onToolActivity: (activity) => {
323
452
  if (activity.type === "end") record.toolUses++;
324
453
  },
454
+ onAssistantUsage: (usage) => {
455
+ addUsage(record.lifetimeUsage, usage);
456
+ },
457
+ onCompaction: (info) => {
458
+ record.compactionCount++;
459
+ this.onCompact?.(record, info);
460
+ },
325
461
  signal,
326
- subagentId: id,
327
462
  });
328
463
  record.status = "completed";
329
464
  record.result = responseText;
@@ -337,6 +472,27 @@ export class AgentManager {
337
472
  return record;
338
473
  }
339
474
 
475
+ /**
476
+ * Send a steering message to an agent from the UI (mirrors the steer_subagent
477
+ * tool). A live session delivers it now — it interrupts the agent after its
478
+ * current tool execution and appears as a user message. If the session isn't
479
+ * ready yet, the message is queued on `pendingSteers` and flushed when the
480
+ * session is created. Returns false if the agent can't accept steering
481
+ * (unknown id, or no longer running/queued).
482
+ */
483
+ steer(id: string, message: string): boolean {
484
+ const record = this.agents.get(id);
485
+ if (!record) return false;
486
+ if (record.status !== "running" && record.status !== "queued") return false;
487
+ if (record.session) {
488
+ record.session.steer(message).catch(() => {});
489
+ } else {
490
+ if (!record.pendingSteers) record.pendingSteers = [];
491
+ record.pendingSteers.push(message);
492
+ }
493
+ return true;
494
+ }
495
+
340
496
  getRecord(id: string): AgentRecord | undefined {
341
497
  return this.agents.get(id);
342
498
  }
@@ -385,10 +541,13 @@ export class AgentManager {
385
541
  /**
386
542
  * Remove all completed/stopped/errored records immediately.
387
543
  * Called on session start/switch so tasks from a prior session don't persist.
544
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
545
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
388
546
  */
389
- clearCompleted(): void {
547
+ clearCompleted(skipUnconsumed = false): void {
390
548
  for (const [id, record] of this.agents) {
391
549
  if (record.status === "running" || record.status === "queued") continue;
550
+ if (skipUnconsumed && !record.resultConsumed) continue;
392
551
  this.removeRecord(id, record);
393
552
  }
394
553
  }
@@ -450,5 +609,10 @@ export class AgentManager {
450
609
  this.agents.clear();
451
610
  // Prune any orphaned git worktrees (crash recovery)
452
611
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
612
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
613
+ // exit with in-flight agents would otherwise leave stale registrations there.
614
+ for (const repo of this.worktreeRepos) {
615
+ try { pruneWorktrees(repo); } catch { /* ignore */ }
616
+ }
453
617
  }
454
618
  }