@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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.
@@ -3,23 +3,46 @@
3
3
  *
4
4
  * Background agents are subject to a configurable concurrency limit (default: 4).
5
5
  * Excess agents are queued and auto-started as running agents complete.
6
- * Foreground agents bypass the queue (they block the parent anyway).
6
+ * Nested children are exempt from that pool — see `occupiesPoolSlot`.
7
+ * Resumes are exempt from the admission gate: they start immediately and are
8
+ * counted while they run, so a parent-visible continuation is never silently
9
+ * deferred behind fresh spawns — a burst of resumes can therefore exceed the
10
+ * limit by design.
7
11
  */
8
12
 
9
13
  import { randomUUID } from "node:crypto";
10
- import { statSync } from "node:fs";
14
+ import { rmSync, statSync } from "node:fs";
11
15
  import { isAbsolute } from "node:path";
12
16
  import type { Model } from "@earendil-works/pi-ai";
13
17
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
18
  import { getDefaultMaxTurns, normalizeMaxTurns, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
15
19
  import { getAgentConfig } from "./agent-types.js";
16
- import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
17
- import { addUsage } from "./usage.js";
18
- import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
20
+ import { describeModel } from "./model-resolver.js";
21
+ import type { AgentConfig, AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
22
+ import { addUsage, type LifetimeUsage } from "./usage.js";
23
+ import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js";
19
24
 
20
25
  export type OnAgentComplete = (record: AgentRecord) => void;
21
26
  export type OnAgentStart = (record: AgentRecord) => void;
27
+ /**
28
+ * Fired by `steer()` once a message is delivered or queued. Every steer path
29
+ * that goes through the manager reports through it — the widget, the fleet
30
+ * list, the conversation viewer, and the cross-extension RPC. The steer tools
31
+ * (`steer_subagent` in index.ts and its nested twin) bypass this method because
32
+ * they await the session directly, so they emit on their own.
33
+ */
34
+ export type OnAgentSteered = (record: AgentRecord, message: string) => void;
22
35
  export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
36
+ /**
37
+ * Fired once per assistant `message_end`, for EVERY agent this manager owns —
38
+ * top-level and nested alike, spawns and resumes. The one place where each
39
+ * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
40
+ * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
41
+ * shows up on the record a human can see, which makes those records useless as
42
+ * a basis for anything that must not count a message twice — parent-session
43
+ * accounting above all.
44
+ */
45
+ export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void;
23
46
  export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
24
47
 
25
48
  /** Default max concurrent background agents. */
@@ -47,6 +70,19 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
47
70
  }
48
71
  }
49
72
 
73
+ /**
74
+ * Whether a record occupies one of the `maxConcurrent` background slots.
75
+ * Nested children don't: their parent already holds a slot, so counting (and
76
+ * therefore queueing) them would deadlock a parent that waits on its own child.
77
+ *
78
+ * Note this bounds nothing horizontally — the depth cap limits how DEEP nesting
79
+ * goes, not how WIDE. A parent's only limit on concurrent children is that each
80
+ * spawn costs it a turn, which is unbounded when max turns is unlimited.
81
+ */
82
+ function occupiesPoolSlot(record: Pick<AgentRecord, "parentAgentId">): boolean {
83
+ return record.parentAgentId === undefined;
84
+ }
85
+
50
86
  interface SpawnArgs {
51
87
  pi: ExtensionAPI;
52
88
  ctx: ExtensionContext;
@@ -55,14 +91,13 @@ interface SpawnArgs {
55
91
  options: SpawnOptions;
56
92
  }
57
93
 
58
- interface SpawnOptions {
94
+ export interface SpawnOptions {
59
95
  description: string;
60
96
  model?: Model<any>;
61
97
  maxTurns?: number;
62
98
  isolated?: boolean;
63
99
  inheritContext?: boolean;
64
100
  thinkingLevel?: ThinkingLevel;
65
- isBackground?: boolean;
66
101
  /**
67
102
  * Skip the maxConcurrent queue check for this spawn — start immediately even
68
103
  * if the configured concurrency limit would otherwise queue it. Used by the
@@ -82,7 +117,14 @@ interface SpawnOptions {
82
117
  cwd?: string;
83
118
  /** Resolved invocation snapshot captured for UI display. */
84
119
  invocation?: AgentInvocation;
85
- /** Parent abort signal — when aborted, the subagent is also stopped. */
120
+ /**
121
+ * Parent abort signal — when aborted, the subagent is also stopped.
122
+ * Intended for in-process callers holding the manager directly (e.g. through
123
+ * the manager registry). Cross-extension RPC callers should pass serializable
124
+ * option values: `signal` is not part of the documented RPC surface and may
125
+ * not survive a serialized boundary. The `Agent` tool no longer forwards its
126
+ * tool-call signal to child runs.
127
+ */
86
128
  signal?: AbortSignal;
87
129
  /** Called on tool start/end with activity info (for streaming progress to UI). */
88
130
  onToolActivity?: (activity: ToolActivity) => void;
@@ -96,14 +138,39 @@ interface SpawnOptions {
96
138
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
97
139
  /** Called when the session successfully compacts. */
98
140
  onCompaction?: (info: CompactionInfo) => void;
141
+ /** Nesting depth: a top-level subagent is 1. */
142
+ depth?: number;
143
+ /** Parent agent ID for ownership-scoped nested controls. */
144
+ parentAgentId?: string;
145
+ /** Effective inherited nesting cap for this branch. */
146
+ maxSubagentDepth?: number;
147
+ /** Config-discovery root inherited by nested launches when it differs from the working directory. */
148
+ configCwd?: string;
149
+ /** Root session id, inherited by nested launches so transcripts stay grouped. */
150
+ rootSessionId?: string;
151
+ /**
152
+ * Config already resolved by the caller's own registry. A nested spawn reads
153
+ * its branch's config root, which may differ from the process-global registry
154
+ * `getAgentConfig` reads; without this the run would resolve the type again
155
+ * and could fall back to the unknown-type default.
156
+ */
157
+ agentConfig?: AgentConfig;
99
158
  }
100
159
 
160
+ /** The five activity callbacks a resumed run forwards to its caller. */
161
+ type ResumeCallbacks = Pick<
162
+ SpawnOptions,
163
+ "onToolActivity" | "onTextDelta" | "onTurnEnd" | "onAssistantUsage" | "onCompaction"
164
+ >;
165
+
101
166
  export class AgentManager {
102
167
  private agents = new Map<string, AgentRecord>();
103
168
  private cleanupInterval: ReturnType<typeof setInterval>;
104
169
  private onComplete?: OnAgentComplete;
105
170
  private onStart?: OnAgentStart;
106
171
  private onCompact?: OnAgentCompact;
172
+ private onUsage?: OnAgentUsage;
173
+ private onSteered?: OnAgentSteered;
107
174
  private maxConcurrent: number;
108
175
  /** Base repos worktrees were created from — so dispose() can prune them all,
109
176
  * not just the parent repo (caller-supplied cwd can target other repos). */
@@ -119,10 +186,14 @@ export class AgentManager {
119
186
  maxConcurrent = DEFAULT_MAX_CONCURRENT,
120
187
  onStart?: OnAgentStart,
121
188
  onCompact?: OnAgentCompact,
189
+ onUsage?: OnAgentUsage,
190
+ onSteered?: OnAgentSteered,
122
191
  ) {
123
192
  this.onComplete = onComplete;
124
193
  this.onStart = onStart;
125
194
  this.onCompact = onCompact;
195
+ this.onUsage = onUsage;
196
+ this.onSteered = onSteered;
126
197
  this.maxConcurrent = maxConcurrent;
127
198
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
128
199
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
@@ -162,7 +233,7 @@ export class AgentManager {
162
233
  id,
163
234
  type,
164
235
  description: options.description,
165
- status: options.isBackground ? "queued" : "running",
236
+ status: "queued",
166
237
  toolUses: 0,
167
238
  startedAt: Date.now(),
168
239
  abortController,
@@ -172,30 +243,37 @@ export class AgentManager {
172
243
  turnCount: 1,
173
244
  // max_turns: 0 (unlimited) maps to undefined; the full fallback chain
174
245
  // is resolved here so every spawn path agrees. See AgentRecord.effectiveMaxTurns.
175
- effectiveMaxTurns: normalizeMaxTurns(options.maxTurns ?? getAgentConfig(type)?.maxTurns ?? getDefaultMaxTurns()),
176
- // Raw tri-state (not coerced to a boolean): true = background, false =
177
- // foreground (has an inline tool-result surface), undefined = caller never
178
- // declared it (e.g. a cross-extension RPC spawn). The widget's background-
179
- // only filter excludes only explicit `false`, so undefined agents — which
180
- // have no inline surface — stay visible instead of vanishing.
181
- isBackground: options.isBackground,
246
+ effectiveMaxTurns: normalizeMaxTurns(
247
+ options.maxTurns ?? (options.agentConfig ?? getAgentConfig(type))?.maxTurns ?? getDefaultMaxTurns(),
248
+ ),
249
+ // Retention is read from the resolved agent config here, at spawn, so the
250
+ // removal path never re-resolves the registry. A resume reuses the record
251
+ // and therefore the decision its first run made.
252
+ persistSession: (options.agentConfig ?? getAgentConfig(type))?.persistSession === true,
182
253
  invocation: options.invocation,
254
+ depth: options.depth ?? 1,
255
+ parentAgentId: options.parentAgentId,
256
+ maxSubagentDepth: options.maxSubagentDepth,
257
+ rootSessionId: options.rootSessionId,
183
258
  };
184
259
  this.agents.set(id, record);
185
260
 
186
261
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
187
262
 
188
- if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
263
+ if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
189
264
  // Queue it — will be started when a running agent completes
190
265
  this.queue.push({ id, args });
191
266
  return id;
192
267
  }
193
268
 
194
- // startAgent can throw (e.g. strict worktree-isolation failure) — clean
195
- // up the record so callers don't see an orphan in `listAgents()`.
269
+ // startAgent can throw (strict worktree-isolation failure, or a throwing
270
+ // `subagents:started` listener after the worktree was created) — reclaim
271
+ // any prologue worktree and drop the record so callers don't see an
272
+ // orphan in `listAgents()`.
196
273
  try {
197
274
  this.startAgent(id, record, args);
198
275
  } catch (err) {
276
+ this.reclaimWorktree(record, options.cwd ?? ctx.cwd);
199
277
  this.agents.delete(id);
200
278
  throw err;
201
279
  }
@@ -204,6 +282,14 @@ export class AgentManager {
204
282
 
205
283
  /** Actually start an agent (called immediately or from queue drain). */
206
284
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
285
+ // An already-aborted parent signal never fires "abort" again — wiring it
286
+ // alone would start a run under a signal its owner considers dead. Settle
287
+ // through the stopped tail before anything is created: no cwd/worktree
288
+ // validation, no started event, no runner call, no slot, one report.
289
+ if (options.signal?.aborted) {
290
+ this.settleStoppedWithoutRun(record);
291
+ return;
292
+ }
207
293
  // Re-validate a caller-supplied cwd: queued spawns can start minutes after
208
294
  // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
209
295
  // curated errors; drainQueue parks a throw on the record as an error.
@@ -216,9 +302,12 @@ export class AgentManager {
216
302
  // Worktree isolation: try to create a temporary git worktree. Strict —
217
303
  // fail loud if not possible (no silent fallback to main tree). Done
218
304
  // BEFORE state mutation so a throw doesn't leave the record half-running.
305
+ // The project switch is enforced here as well as at the tool boundary
306
+ // because cross-extension RPC forwards its options unvalidated — a schema
307
+ // that omits the field can't stop a caller that never saw the schema.
219
308
  let worktreeCwd: string | undefined;
220
309
  let worktreePromptInfo: { path: string; parentCwd: string } | undefined;
221
- if (options.isolation === "worktree") {
310
+ if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
222
311
  const wt = createWorktree(baseCwd, id);
223
312
  if (!wt) {
224
313
  throw new Error(
@@ -240,9 +329,16 @@ export class AgentManager {
240
329
 
241
330
  record.status = "running";
242
331
  record.startedAt = Date.now();
243
- if (options.isBackground) this.runningBackground++;
244
332
  this.onStart?.(record);
245
333
 
334
+ // A started listener can stop the record synchronously (an extension
335
+ // reacting to `subagents:started`). Settle it through the stopped tail
336
+ // instead of starting — and counting — a run the user already stopped.
337
+ if (this.stoppedInStartPrologue(record)) {
338
+ this.settleStoppedWithoutRun(record, baseCwd);
339
+ return;
340
+ }
341
+
246
342
  // Wire parent abort signal to stop the subagent when the parent is interrupted
247
343
  let detachParentSignal: (() => void) | undefined;
248
344
  if (options.signal) {
@@ -252,6 +348,11 @@ export class AgentManager {
252
348
  }
253
349
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
254
350
 
351
+ // Increment only after every synchronous throw site (cwd/worktree
352
+ // validation, onStart) — the runner calls are async, so any later throw
353
+ // arrives as a rejection through .catch/.finally and cannot leak the slot.
354
+ if (occupiesPoolSlot(record)) this.runningBackground++;
355
+ record.settled = false;
255
356
  const promise = runAgent(ctx, type, prompt, {
256
357
  pi,
257
358
  agentId: id,
@@ -260,6 +361,9 @@ export class AgentManager {
260
361
  isolated: options.isolated,
261
362
  inheritContext: options.inheritContext,
262
363
  thinkingLevel: options.thinkingLevel,
364
+ // Resolved by the caller's own registry (a nested spawn reads its branch's
365
+ // config root), so the run does not re-resolve it against the global one.
366
+ agentConfig: options.agentConfig,
263
367
  // Worktree wins for the working dir (the agent must run in the copy —
264
368
  // which, with a custom cwd, was created from that target). Config stays
265
369
  // with the parent project when a caller-supplied cwd is in play; it must
@@ -267,7 +371,7 @@ export class AgentManager {
267
371
  // (incl. relative extension paths and memory) inside the worktree copy.
268
372
  cwd: worktreeCwd ?? customCwd,
269
373
  worktree: worktreePromptInfo,
270
- configCwd: customCwd !== undefined ? ctx.cwd : undefined,
374
+ configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
271
375
  signal: record.abortController!.signal,
272
376
  onToolActivity: (activity) => {
273
377
  if (activity.type === "end") record.toolUses++;
@@ -280,6 +384,7 @@ export class AgentManager {
280
384
  onTextDelta: options.onTextDelta,
281
385
  onAssistantUsage: (usage) => {
282
386
  addUsage(record.lifetimeUsage, usage);
387
+ this.onUsage?.(record, usage);
283
388
  options.onAssistantUsage?.(usage);
284
389
  },
285
390
  onCompaction: (info) => {
@@ -287,9 +392,38 @@ export class AgentManager {
287
392
  this.onCompact?.(record, info);
288
393
  options.onCompaction?.(info);
289
394
  },
395
+ nestedRuntime: {
396
+ manager: this,
397
+ parentAgentId: id,
398
+ depth: record.depth ?? 1,
399
+ maxSubagentDepth: record.maxSubagentDepth,
400
+ },
290
401
  onSessionCreated: (session) => {
291
402
  record.session = session;
292
403
  record.sessionId = session.sessionId;
404
+ // The model and thinking level are only knowable once pi has resolved
405
+ // its defaults and clamped the level to what the model supports. Writing
406
+ // them back here makes the record authoritative, so every surface reads
407
+ // one place instead of each re-deriving "session, else the request" for
408
+ // itself. An agent started outside the Agent tool (RPC, scheduler) has
409
+ // no invocation yet and gains one here.
410
+ if (session.model) {
411
+ record.invocation ??= {};
412
+ // Read the kept request first: a caller's level survives being clamped
413
+ // AND, one line later, being replaced by the effective one.
414
+ const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
415
+ Object.assign(record.invocation, describeModel(session.model));
416
+ // Guarded: a session that reports no level keeps the request rather
417
+ // than losing it. Overwriting unconditionally would turn a stubbed or
418
+ // older session into a blank `thinking:` tag, which is worse than the
419
+ // stale-but-true value it replaced.
420
+ if (session.thinkingLevel) {
421
+ record.invocation.thinking = session.thinkingLevel;
422
+ if (requested && requested !== session.thinkingLevel) {
423
+ record.invocation.requestedThinking = requested;
424
+ }
425
+ }
426
+ }
293
427
  // Flush any steers that arrived before the session was ready
294
428
  if (record.pendingSteers?.length) {
295
429
  for (const msg of record.pendingSteers) {
@@ -321,12 +455,6 @@ export class AgentManager {
321
455
 
322
456
  detach();
323
457
 
324
- // Final flush of streaming output file
325
- if (record.outputCleanup) {
326
- try { record.outputCleanup(); } catch { /* ignore */ }
327
- record.outputCleanup = undefined;
328
- }
329
-
330
458
  // Clean up worktree if used
331
459
  if (record.worktree) {
332
460
  const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
@@ -340,16 +468,6 @@ export class AgentManager {
340
468
  }
341
469
  }
342
470
 
343
- // Fire onComplete for foreground agents too — lifecycle symmetry.
344
- // Mark resultConsumed so the callback skips notifications (result returned inline).
345
- if (!options.isBackground) {
346
- record.resultConsumed = true;
347
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
348
- } else {
349
- this.runningBackground--;
350
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
351
- this.drainQueue();
352
- }
353
471
  return responseText;
354
472
  })
355
473
  .catch((err) => {
@@ -362,12 +480,6 @@ export class AgentManager {
362
480
 
363
481
  detach();
364
482
 
365
- // Final flush of streaming output file on error
366
- if (record.outputCleanup) {
367
- try { record.outputCleanup(); } catch { /* ignore */ }
368
- record.outputCleanup = undefined;
369
- }
370
-
371
483
  // Best-effort worktree cleanup on error
372
484
  if (record.worktree) {
373
485
  try {
@@ -376,25 +488,96 @@ export class AgentManager {
376
488
  } catch { /* ignore cleanup errors */ }
377
489
  }
378
490
 
379
- // Fire onComplete for foreground agents too — lifecycle symmetry.
380
- // Mark resultConsumed so the callback skips notifications (result returned inline).
381
- if (!options.isBackground) {
382
- record.resultConsumed = true;
383
- this.onComplete?.(record);
384
- } else {
385
- this.runningBackground--;
386
- this.onComplete?.(record);
387
- this.drainQueue();
388
- }
389
491
  return "";
390
- });
492
+ })
493
+ .finally(() => this.afterRun(record));
391
494
 
392
495
  record.promise = promise;
496
+ }
497
+
498
+ /** Shared completion tail: release the slot, stop orphaned children, flush the transcript, notify, drain. */
499
+ private afterRun(record: AgentRecord): void {
500
+ if (occupiesPoolSlot(record)) this.runningBackground--;
501
+ // Children spawned by this run must not outlive it — they are hidden from
502
+ // every surface and reachable only through this agent, so an orphan would
503
+ // burn tokens unseen. Runs on every settle path, which is what also covers
504
+ // a resumed turn: grandchildren follow transitively, each through its own
505
+ // settle tail.
506
+ this.abortOwnedChildren(record.id);
507
+ try { record.outputCleanup?.(); } catch { /* ignore */ }
508
+ record.outputCleanup = undefined;
509
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
510
+ // A drain escape must not reject an already-fulfilled run promise or strand
511
+ // `settled` — a stuck `settled = false` would refuse every later resume and
512
+ // leave the queued records behind the freed slot unstarted.
513
+ try { this.drainQueue(); } catch { /* keep the completion tail failure-safe */ }
514
+ record.settled = true;
515
+ }
516
+
517
+ /**
518
+ * Settle a record whose run never started — stopped while queued, or stopped
519
+ * by a `subagents:started` listener before the runner was wired. Reports the
520
+ * stop through the completion surface exactly once and marks the record fully
521
+ * unwound. No counter change: a never-started record never held a slot.
522
+ *
523
+ * `baseCwd` is the repo a prologue-created worktree came from; omit it on
524
+ * paths where this run's prologue created none (a record stopped while
525
+ * queued, an already-aborted parent signal, or a resume stopped during its
526
+ * start prologue). A resumed record can still carry the prior run's worktree
527
+ * reference — a resume never creates one, and reclaiming that stale path
528
+ * would clobber the prior run's `worktreeResult`.
529
+ */
530
+ private settleStoppedWithoutRun(record: AgentRecord, baseCwd?: string): void {
531
+ record.status = "stopped";
532
+ record.completedAt ??= Date.now();
533
+ // No turn ever executed — zero the run-local counter so the completion
534
+ // report cannot claim the presumed first turn.
535
+ record.turnCount = 0;
536
+ // Reclaim a worktree created by the start prologue (a stop issued during
537
+ // the `subagents:started` emit): the run never made changes, so this
538
+ // removes the copy and its git registration outright. `baseCwd` is absent
539
+ // on the queued-abort, already-aborted-signal, and resume start-prologue
540
+ // paths, where this run's prologue created no worktree.
541
+ if (baseCwd) {
542
+ this.reclaimWorktree(record, baseCwd);
543
+ }
544
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
545
+ record.settled = true;
546
+ }
393
547
 
394
- // Notify caller that spawn is complete (record is in the map, promise is set).
395
- // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
396
- // Used by spawnAndWait to let the caller set up output files before streaming starts.
397
- this.onSpawned?.(id);
548
+ /**
549
+ * Best-effort reclamation of a worktree the start prologue created when the
550
+ * run never executed: a throwing started listener on the spawn or queue
551
+ * path, or a stop issued during the `subagents:started` emit. Removes the
552
+ * copy and its git registration from `baseCwd`'s repo, records the cleanup
553
+ * outcome, and clears the record's reference so no later path retries it.
554
+ * Cleanup errors are ignored — the failure or stop that triggered the
555
+ * reclaim is the actionable signal.
556
+ */
557
+ private reclaimWorktree(record: AgentRecord, baseCwd: string): void {
558
+ if (!record.worktree) return;
559
+ try {
560
+ record.worktreeResult = cleanupWorktree(baseCwd, record.worktree, record.description);
561
+ } catch { /* ignore cleanup errors */ }
562
+ record.worktree = undefined;
563
+ }
564
+
565
+ /** True when a started listener stopped the record or aborted its controller
566
+ * during the synchronous `onStart` emit. */
567
+ private stoppedInStartPrologue(record: AgentRecord): boolean {
568
+ return record.status === "stopped" || record.abortController?.signal.aborted === true;
569
+ }
570
+
571
+ /**
572
+ * Stop the nested children a settled parent owns. Nested records are hidden
573
+ * from the UI and only their owner can consume them, so a child outliving its
574
+ * parent would burn tokens unseen with no way to reach it. Grandchildren are
575
+ * covered transitively — each abort lands in that child's own settle path.
576
+ */
577
+ private abortOwnedChildren(parentId: string): void {
578
+ for (const [id, record] of this.agents) {
579
+ if (record.parentAgentId === parentId) this.abort(id);
580
+ }
398
581
  }
399
582
 
400
583
  /** Start queued agents up to the concurrency limit. */
@@ -402,99 +585,137 @@ export class AgentManager {
402
585
  while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
403
586
  const next = this.queue.shift()!;
404
587
  const record = this.agents.get(next.id);
405
- if (!record || record.status !== "queued") continue;
588
+ if (record?.status !== "queued") continue;
406
589
  try {
407
590
  this.startAgent(next.id, record, next.args);
408
591
  } catch (err) {
409
- // Late failure (e.g. strict worktree-isolation) — surface on the record
410
- // so the user/agent can see it via /agents, then keep draining.
592
+ // Only pre-increment throws reach here (cwd re-validation, worktree
593
+ // creation, onStart) — the counter needs no rollback. Surface the failure
594
+ // on the record so the user/agent can see it via /agents, then keep draining.
411
595
  record.status = "error";
412
596
  record.error = err instanceof Error ? err.message : String(err);
413
597
  record.completedAt = Date.now();
414
- this.onComplete?.(record);
598
+ // No turn ever executed — zero the run-local counter so the completion
599
+ // report cannot claim the presumed first turn.
600
+ record.turnCount = 0;
601
+ // A throwing onStart can follow worktree creation: reclaim the copy
602
+ // and its registration (best-effort, like the run handlers' cleanup).
603
+ this.reclaimWorktree(record, next.args.options.cwd ?? next.args.ctx.cwd);
604
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
415
605
  }
416
606
  }
417
607
  }
418
608
 
419
609
  /**
420
- * Called synchronously right after spawn, before onSessionCreated fires.
421
- * Lets the caller set up the output file path on the record.
422
- * The record is guaranteed to be in this.agents at this point.
423
- */
424
- private onSpawned?: (id: string) => void;
425
-
426
- /**
427
- * Spawn an agent and wait for completion (foreground use).
428
- * Foreground agents bypass the concurrency queue.
429
- * Returns { id, record } so callers can access the agent ID.
610
+ * Kick off a resume of an existing agent session with a new prompt.
430
611
  *
431
- * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
432
- * Use this to set record.outputFile so streamToOutputFile can pick it up.
433
- */
434
- async spawnAndWait(
435
- pi: ExtensionAPI,
436
- ctx: ExtensionContext,
437
- type: SubagentType,
438
- prompt: string,
439
- options: Omit<SpawnOptions, "isBackground">,
440
- onSpawned?: (id: string) => void,
441
- ): Promise<{ id: string; record: AgentRecord }> {
442
- // Temporarily register the onSpawned hook so startAgent can call it.
443
- const prevOnSpawned = this.onSpawned;
444
- this.onSpawned = onSpawned;
445
- try {
446
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
447
- const record = this.agents.get(id)!;
448
- await record.promise;
449
- return { id, record };
450
- } finally {
451
- this.onSpawned = prevOnSpawned;
452
- }
453
- }
454
-
455
- /**
456
- * Resume an existing agent session with a new prompt.
612
+ * Returns the record synchronously; the run settles through `record.promise`
613
+ * and the shared completion tail. Returns `undefined` when the record is
614
+ * unknown, has no session, is already active or winding down, when the
615
+ * started listener throws, or when it stops the record during the
616
+ * `subagents:started` emit.
617
+ *
618
+ * Resumes bypass the maxConcurrent admission gate by design: a continuation
619
+ * of a parent-visible run must start immediately rather than queue behind
620
+ * fresh spawns. It still occupies a pool slot while running, so a burst of
621
+ * resumes can push the actual concurrency above the limit.
457
622
  */
458
- async resume(
459
- id: string,
460
- prompt: string,
461
- signal?: AbortSignal,
462
- ): Promise<AgentRecord | undefined> {
623
+ resume(id: string, prompt: string, callbacks?: ResumeCallbacks): AgentRecord | undefined {
463
624
  const record = this.agents.get(id);
464
625
  if (!record?.session) return undefined;
626
+ // A record in the start prologue is already `running` while `settled` may
627
+ // still hold the prior run's `true`, so the running arm is load-bearing.
628
+ // A queued record has no session yet and is refused by the queued arm.
629
+ if (record.status === "running" || record.status === "queued" || record.settled === false) {
630
+ return undefined;
631
+ }
465
632
 
633
+ const prior = {
634
+ status: record.status,
635
+ startedAt: record.startedAt,
636
+ completedAt: record.completedAt,
637
+ result: record.result,
638
+ error: record.error,
639
+ resultConsumed: record.resultConsumed,
640
+ abortController: record.abortController,
641
+ turnCount: record.turnCount,
642
+ lastCheckpoint: record.lastCheckpoint,
643
+ };
466
644
  record.status = "running";
467
645
  record.startedAt = Date.now();
468
646
  record.completedAt = undefined;
469
647
  record.result = undefined;
470
648
  record.error = undefined;
471
-
649
+ record.resultConsumed = undefined; // a prior pull must not swallow the resume report
650
+ // Run-local, mirroring a fresh spawn's initial turn count.
651
+ record.turnCount = 1;
652
+ // Run-local too: the prior run's latest checkpoint must not surface as the
653
+ // resumed run's state. The .checkpoints.md history stays cumulative.
654
+ record.lastCheckpoint = undefined;
655
+ record.abortController = new AbortController();
472
656
  try {
473
- const { text, failure } = await resumeAgent(record.session, prompt, {
474
- onToolActivity: (activity) => {
475
- if (activity.type === "end") record.toolUses++;
476
- },
477
- onAssistantUsage: (usage) => {
478
- addUsage(record.lifetimeUsage, usage);
479
- },
480
- onCompaction: (info) => {
481
- record.compactionCount++;
482
- this.onCompact?.(record, info);
483
- },
484
- signal,
485
- });
486
- // Same contract as the spawn path (#144): a failed final turn is an
487
- // error, not a completion — but the resumed text stays available.
488
- record.status = failure ? "error" : "completed";
489
- if (failure) record.error = failure;
490
- record.result = text;
491
- record.completedAt = Date.now();
492
- } catch (err) {
493
- record.status = "error";
494
- record.error = err instanceof Error ? err.message : String(err);
495
- record.completedAt = Date.now();
657
+ this.onStart?.(record);
658
+ } catch {
659
+ // A listener threw (stale extension context). Restore the pre-resume
660
+ // state and refuse: the tool reports failure and the counter is untouched.
661
+ Object.assign(record, prior);
662
+ return undefined;
496
663
  }
497
-
664
+ // A started listener can stop the record synchronously; settle it through
665
+ // the stopped tail instead of starting — and counting — a run that was
666
+ // already stopped.
667
+ if (this.stoppedInStartPrologue(record)) {
668
+ this.settleStoppedWithoutRun(record);
669
+ return undefined;
670
+ }
671
+ record.settled = false;
672
+ // Occupies a slot but is never gated by maxConcurrent — a burst of resumes
673
+ // can exceed the cap by design. They share the pool counter, so queued
674
+ // fresh spawns wait for these resumes to settle before drainQueue admits them.
675
+ // A nested child is not in the pool (`occupiesPoolSlot`), and afterRun only
676
+ // decrements for records that are: counting it here leaked a slot per nested
677
+ // resume until every top-level spawn queued forever.
678
+ if (occupiesPoolSlot(record)) this.runningBackground++;
679
+ const promise = resumeAgent(record.session, prompt, {
680
+ onToolActivity: (activity) => {
681
+ if (activity.type === "end") record.toolUses++;
682
+ callbacks?.onToolActivity?.(activity);
683
+ },
684
+ onTextDelta: callbacks?.onTextDelta,
685
+ onTurnEnd: (turnCount) => {
686
+ record.turnCount = turnCount;
687
+ callbacks?.onTurnEnd?.(turnCount);
688
+ },
689
+ onAssistantUsage: (usage) => {
690
+ addUsage(record.lifetimeUsage, usage);
691
+ this.onUsage?.(record, usage);
692
+ callbacks?.onAssistantUsage?.(usage);
693
+ },
694
+ onCompaction: (info) => {
695
+ record.compactionCount++;
696
+ this.onCompact?.(record, info);
697
+ callbacks?.onCompaction?.(info);
698
+ },
699
+ signal: record.abortController.signal,
700
+ })
701
+ .then(({ text, failure }) => {
702
+ // Stop wins; a stopped resume is never overwritten with completed.
703
+ if (record.status !== "stopped") {
704
+ record.status = failure ? "error" : "completed";
705
+ if (failure) record.error = failure;
706
+ }
707
+ record.result = text;
708
+ record.completedAt ??= Date.now();
709
+ return text;
710
+ })
711
+ .catch((err) => {
712
+ if (record.status !== "stopped") record.status = "error";
713
+ record.error = err instanceof Error ? err.message : String(err);
714
+ record.completedAt ??= Date.now();
715
+ return "";
716
+ })
717
+ .finally(() => this.afterRun(record));
718
+ record.promise = promise;
498
719
  return record;
499
720
  }
500
721
 
@@ -504,7 +725,10 @@ export class AgentManager {
504
725
  * current tool execution and appears as a user message. If the session isn't
505
726
  * ready yet, the message is queued on `pendingSteers` and flushed when the
506
727
  * session is created. Returns false if the agent can't accept steering
507
- * (unknown id, or no longer running/queued).
728
+ * (unknown id, or no longer running/queued). Fires `onSteered` on either
729
+ * accepted path, so the viewers and the cross-extension RPC report the steer
730
+ * the same way without each emitting by hand. The steer tools bypass this
731
+ * method because they await delivery.
508
732
  */
509
733
  steer(id: string, message: string): boolean {
510
734
  const record = this.agents.get(id);
@@ -516,6 +740,7 @@ export class AgentManager {
516
740
  if (!record.pendingSteers) record.pendingSteers = [];
517
741
  record.pendingSteers.push(message);
518
742
  }
743
+ this.onSteered?.(record, message);
519
744
  return true;
520
745
  }
521
746
 
@@ -536,8 +761,7 @@ export class AgentManager {
536
761
  // Remove from queue if queued
537
762
  if (record.status === "queued") {
538
763
  this.queue = this.queue.filter(q => q.id !== id);
539
- record.status = "stopped";
540
- record.completedAt = Date.now();
764
+ this.settleStoppedWithoutRun(record);
541
765
  return true;
542
766
  }
543
767
 
@@ -548,17 +772,47 @@ export class AgentManager {
548
772
  return true;
549
773
  }
550
774
 
551
- /** Dispose a record's session and remove it from the map. */
775
+ /**
776
+ * Dispose a record's session and remove it from the map.
777
+ *
778
+ * The record's removal is also the child session file's disposal point: the
779
+ * 10-minute eviction and `clearCompleted()` on session switch are the grace
780
+ * window in which a live record can still resume the agent, so nothing may
781
+ * delete the file earlier. A `persist_session` agent keeps its file
782
+ * indefinitely; every other settled agent's file goes here.
783
+ *
784
+ * A run still in flight keeps its file even at removal: deleting the file a
785
+ * live child is still appending to loses the transcript the crash-recovery
786
+ * case exists for. The callers that could reach one — eviction and the session
787
+ * switch — skip it for the same reason, so this is the second line of defence,
788
+ * not the only one.
789
+ *
790
+ * Best-effort: a missing file, a permission error or a Windows lock must not
791
+ * throw into the completion/eviction path.
792
+ */
552
793
  private removeRecord(id: string, record: AgentRecord): void {
794
+ // Read from the session, not a copy taken at creation: pi moves the file on
795
+ // `ctx.newSession()`/`fork()`, and deleting the path it started with would
796
+ // leak the one it actually wrote.
797
+ const sessionFile = record.session?.sessionManager.getSessionFile();
553
798
  record.session?.dispose?.();
554
799
  record.session = undefined;
555
800
  this.agents.delete(id);
801
+ // After dispose, so the file handle is closed — Windows will not unlink an
802
+ // open file.
803
+ if (record.settled === false || record.persistSession || !sessionFile) return;
804
+ try {
805
+ rmSync(sessionFile, { force: true });
806
+ } catch { /* retention is best-effort — never fail a turn over it */ }
556
807
  }
557
808
 
558
809
  private cleanup() {
559
810
  const cutoff = Date.now() - 10 * 60_000;
560
811
  for (const [id, record] of this.agents) {
561
812
  if (record.status === "running" || record.status === "queued") continue;
813
+ // Stopped but still unwinding: removing it would dispose a session a live
814
+ // run is still writing to, and the file has to outlive the run.
815
+ if (record.settled === false) continue;
562
816
  if ((record.completedAt ?? 0) >= cutoff) continue;
563
817
  this.removeRecord(id, record);
564
818
  }
@@ -573,6 +827,8 @@ export class AgentManager {
573
827
  clearCompleted(skipUnconsumed = false): void {
574
828
  for (const [id, record] of this.agents) {
575
829
  if (record.status === "running" || record.status === "queued") continue;
830
+ // See cleanup(): an unwinding run keeps its record until it settles.
831
+ if (record.settled === false) continue;
576
832
  if (skipUnconsumed && !record.resultConsumed) continue;
577
833
  this.removeRecord(id, record);
578
834
  }
@@ -592,8 +848,7 @@ export class AgentManager {
592
848
  for (const queued of this.queue) {
593
849
  const record = this.agents.get(queued.id);
594
850
  if (record) {
595
- record.status = "stopped";
596
- record.completedAt = Date.now();
851
+ this.settleStoppedWithoutRun(record);
597
852
  count++;
598
853
  }
599
854
  }
@@ -610,14 +865,21 @@ export class AgentManager {
610
865
  return count;
611
866
  }
612
867
 
613
- /** Wait for all running and queued agents to complete (including queued ones). */
868
+ /**
869
+ * Wait for all running and queued agents to complete (including queued ones).
870
+ *
871
+ * A stopped run is still unwinding when its `settled` flag is `false`: it no
872
+ * longer counts as `running` but still holds its slot, so its promise is part
873
+ * of the pending set — the queued records behind it start only once it
874
+ * settles.
875
+ */
614
876
  async waitForAll(): Promise<void> {
615
877
  // Loop because drainQueue respects the concurrency limit — as running
616
878
  // agents finish they start queued ones, which need awaiting too.
617
879
  while (true) {
618
880
  this.drainQueue();
619
881
  const pending = [...this.agents.values()]
620
- .filter(r => r.status === "running" || r.status === "queued")
882
+ .filter(r => r.status === "running" || r.status === "queued" || r.settled === false)
621
883
  .map(r => r.promise)
622
884
  .filter(Boolean);
623
885
  if (pending.length === 0) break;
@@ -629,10 +891,12 @@ export class AgentManager {
629
891
  clearInterval(this.cleanupInterval);
630
892
  // Clear queue
631
893
  this.queue = [];
632
- for (const record of this.agents.values()) {
633
- record.session?.dispose();
894
+ // An in-flight run keeps its session file for the crash-recovery follow-up;
895
+ // a settled one is deleted here, because the manager's map is discarded and
896
+ // nothing would ever revisit it.
897
+ for (const [id, record] of this.agents) {
898
+ this.removeRecord(id, record);
634
899
  }
635
- this.agents.clear();
636
900
  // Prune any orphaned git worktrees (crash recovery)
637
901
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
638
902
  // Also prune repos that caller-supplied cwds created worktrees in — a clean