@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9

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 (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -21,16 +21,21 @@
21
21
  */
22
22
 
23
23
  import { randomUUID } from "node:crypto";
24
- import { statSync } from "node:fs";
24
+ import { accessSync, constants, opendirSync, statSync } from "node:fs";
25
25
  import { isAbsolute } from "node:path";
26
26
  import type { Model } from "@earendil-works/pi-ai";
27
27
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
28
  import { type AssistantMessageInfo, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
29
29
  import { getAgentConfig } from "./agent-types.js";
30
30
  import { type Clock, realClock, type TimerHandle } from "./clock.js";
31
+ import { errorMessage } from "./error-message.js";
31
32
  import { assignHandle, handleBase } from "./mention.js";
32
33
  import type { ModelCallEvent } from "./model-call.js";
33
- import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
34
+ import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
35
+ import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
36
+ import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
37
+ import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
38
+ import { type PlanToolPolicy, queryPlanPolicy } from "./plan-mode-children.js";
34
39
  import {
35
40
  assertWithinBudgetDepth,
36
41
  BUDGET_QUEUE_FULL_ERROR,
@@ -41,10 +46,12 @@ import {
41
46
  releaseBudget,
42
47
  tryAcquireBudget,
43
48
  } from "./process-budget.js";
49
+ import { describeBlock } from "./quota-admission.js";
50
+ import { isStaleCtxError } from "./stale-ctx.js";
44
51
  import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
45
52
  import { addUsage, type LifetimeUsage } from "./usage.js";
46
53
  import type { CompiledSchema } from "./workflow/json-schema.js";
47
- import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
54
+ import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
48
55
 
49
56
  /**
50
57
  * Relabel a record's model from the response that just finished. A virtual
@@ -59,10 +66,32 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
59
66
  if (!physical) return;
60
67
  record.invocation ??= {};
61
68
  Object.assign(record.invocation, physical);
69
+ if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
70
+ record.modelTier = { ...record.modelTier, answered: physical.modelId };
71
+ if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
72
+ }
73
+ }
74
+
75
+ /** Keep a cleanup failure visible on every result, including errors and stopped workflows. */
76
+ function recordWorktreeCleanup(record: AgentRecord, result: WorktreeCleanupResult): void {
77
+ record.worktreeResult = result;
78
+ if (!result.error) return;
79
+ const note = `Worktree cleanup failed: ${result.error}. Worktree retained at \`${result.path}\`${result.branch ? `; branch \`${result.branch}\` was created` : ""}. Inspect and recover it before removing it.`;
80
+ record.error = record.error ? `${record.error}\n${note}` : note;
81
+ record.result = record.result ? `${record.result}\n\n${note}` : note;
82
+ if (record.status !== "stopped") record.status = "error";
62
83
  }
63
84
 
64
85
  export type OnAgentComplete = (record: AgentRecord) => void;
65
86
  export type OnAgentStart = (record: AgentRecord) => void;
87
+ /**
88
+ * Fired when `abort`, `detach` or `abortAll` stops a record that was still queued. Such a record
89
+ * never reaches `onComplete` (it never ran, so there is no result to announce), but a listener
90
+ * that heard it spawn needs to learn it ended.
91
+ */
92
+ export type OnAgentQueuedStop = (record: AgentRecord) => void;
93
+ /** Fired when the retention sweep evicts a top-level agent whose result was never read (#330). */
94
+ export type OnAgentReaped = (note: EvictedNote) => void;
66
95
  /**
67
96
  * Fired by `steer()` once a message is delivered or queued. Every steer path
68
97
  * goes through it except the steer tools (`steer_subagent` in index.ts, and its
@@ -163,12 +192,19 @@ export interface EvictedNote {
163
192
  evictedAfterMin: number;
164
193
  /** The `.output` transcript when one was written, else the session file. */
165
194
  transcript?: string;
195
+ /** The run's model fallback, so even an evicted agent's id still discloses it. */
196
+ modelFallback?: ModelFallback;
197
+ modelTier?: TierDisclosure;
198
+ /** Set when a saved session exists but its name now belongs to another agent, so it was not kept reopenable. */
199
+ nameTaken?: string;
166
200
  }
167
201
 
168
202
  /** The text a tool returns for an id whose record was evicted. */
169
203
  export function describeEvicted(note: EvictedNote): string {
170
204
  const where = note.transcript ? `; transcript: ${note.transcript}` : "";
171
- return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}`;
205
+ const disclosure = modelSelectionNote(note);
206
+ const fallback = disclosure ? `\n${disclosure}` : "";
207
+ return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
172
208
  }
173
209
 
174
210
  /**
@@ -200,6 +236,50 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
200
236
  }
201
237
  }
202
238
 
239
+ /**
240
+ * Fail fast when a working directory exists but cannot be opened (#436).
241
+ * `stat` succeeds on such a directory (POSIX `chmod 000`, or a macOS privacy
242
+ * denial of an external volume), so without this the child starts and every
243
+ * read fails with a bare EPERM/EACCES. Only those two codes are rejected: a
244
+ * missing directory stays the caller's concern (`assertValidSpawnCwd`).
245
+ */
246
+ function assertReadableCwd(cwd: string, label: string): void {
247
+ try {
248
+ opendirSync(cwd).closeSync();
249
+ // opendir needs only the read bit; children also need search (x) to reach anything inside.
250
+ accessSync(cwd, constants.R_OK | constants.X_OK);
251
+ } catch (err) {
252
+ const code = (err as NodeJS.ErrnoException).code;
253
+ if (code !== "EACCES" && code !== "EPERM") return;
254
+ throw new Error(
255
+ `${label} is not readable (${code}): "${cwd}". Check its permissions; on macOS also check Privacy & Security -> Files & Folders / Full Disk Access for the terminal app (external volumes need it).`,
256
+ );
257
+ }
258
+ }
259
+
260
+ /** Validate the cwd a child will run in: caller-supplied `SpawnOptions.cwd`, else the session cwd. */
261
+ function assertSpawnCwd(cwd: unknown, sessionCwd: string): void {
262
+ assertValidSpawnCwd(cwd);
263
+ if (cwd == null) assertReadableCwd(sessionCwd, "The session cwd");
264
+ else assertReadableCwd(cwd, "SpawnOptions.cwd");
265
+ }
266
+
267
+ /** The session's own pending agents, split by whether their completion wakes the parent. */
268
+ export interface OwnedPending {
269
+ /** Background agents (Agent tool, `@handle`, scheduler, background resume) whose completion notice starts a parent turn. */
270
+ notifying: number;
271
+ /** Foreground agents and RPC spawns without `isBackground`, whose settle notifies nobody. */
272
+ silent: number;
273
+ }
274
+
275
+ /** A record whose run is over. Every settle and stop stamps `completedAt`; only a (re)start clears it. */
276
+ type SettledRecord = AgentRecord & { completedAt: number };
277
+
278
+ /** Whether `record` is past its run (neither running nor queued), and so carries `completedAt`. */
279
+ function isSettled(record: AgentRecord): record is SettledRecord {
280
+ return record.status !== "running" && record.status !== "queued";
281
+ }
282
+
203
283
  /**
204
284
  * Whether a record occupies one of the `maxConcurrent` background slots.
205
285
  * Nested children don't: their parent already holds a slot, so counting (and
@@ -263,6 +343,8 @@ function occupiesForegroundSlot(
263
343
  /** A start waiting in `AgentManager.queue`; see there. */
264
344
  interface QueueEntry {
265
345
  id: string;
346
+ /** The queued record; it stays in `agents` while queued, since only settled records are evicted. */
347
+ record: AgentRecord;
266
348
  /** The pool it needs room in, or undefined when it waits on the budget alone. */
267
349
  pool?: Pool;
268
350
  start: () => Promise<void>;
@@ -282,6 +364,8 @@ interface SpawnArgs {
282
364
  }
283
365
 
284
366
  interface SpawnOptions {
367
+ /** Home inherited by nested callers; the manager's home is the default. */
368
+ homeDir?: string;
285
369
  description: string;
286
370
  /**
287
371
  * Optional memorable name for this instance, becoming a second handle
@@ -308,7 +392,16 @@ interface SpawnOptions {
308
392
  * `spawnTopLevel` strips it from anything a caller sends.
309
393
  */
310
394
  reclaim?: { handle: string; alias?: string };
311
- model?: Model<any>;
395
+ model?: Model<any> | string;
396
+ /** Internal preflight outcome, stored on the record without resolving again. */
397
+ modelSelection?: ModelSelection;
398
+ /** Historical outcome only, never used to label the continuation. */
399
+ previousModelTier?: TierDisclosure;
400
+ /** Historical fallback only, never used to label the continuation. */
401
+ previousModelFallback?: ModelFallback;
402
+ /** Internal, like `reclaim`: the evicted id a reopen continues. */
403
+ previousId?: string;
404
+
312
405
  maxTurns?: number;
313
406
  isolated?: boolean;
314
407
  inheritContext?: boolean;
@@ -378,9 +471,11 @@ interface SpawnOptions {
378
471
  invocation?: AgentInvocation;
379
472
  /** Parent abort signal — when aborted, the subagent is also stopped. */
380
473
  signal?: AbortSignal;
474
+ /** Prepare the record before it can be queued or started. A throw rejects the spawn. */
475
+ onPrepare?: (id: string) => void;
381
476
  /**
382
477
  * Called synchronously once the record is in the map and its promise is set,
383
- * before `onSessionCreated` fires — where callers attach the output file.
478
+ * before `onSessionCreated` fires.
384
479
  *
385
480
  * Carried on the options rather than parked on the manager for the duration
386
481
  * of a spawn: with a foreground queue, `startAgent` can run at drain time,
@@ -472,6 +567,7 @@ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
472
567
  * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
473
568
  */
474
569
  async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
570
+ let deadline: ReturnType<typeof setTimeout> | undefined;
475
571
  try {
476
572
  const runner = session?.extensionRunner;
477
573
  // Optional all the way down: on a pi without the getter, or a stubbed session from a
@@ -482,14 +578,44 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
482
578
  // already torn down — one hung handler would leave a dead terminal.
483
579
  await Promise.race([
484
580
  runner.emit({ type: "session_shutdown", reason: "quit" }),
485
- new Promise<void>(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()),
581
+ new Promise<void>(resolve => {
582
+ deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
583
+ deadline.unref();
584
+ }),
486
585
  ]);
487
586
  }
488
587
  } catch { /* a partial session must degrade, not take the teardown down with it */ }
588
+ finally {
589
+ // Promise.race does not cancel its loser, even when a handler rejects.
590
+ if (deadline !== undefined) clearTimeout(deadline);
591
+ }
489
592
  // Always, even on timeout: disposal is what this function ultimately exists to do.
490
593
  try { session?.dispose?.(); } catch { /* ignore */ }
491
594
  }
492
595
 
596
+ /**
597
+ * Wrap an observer the child's own agent loop calls (usage, compaction) so a
598
+ * throw cannot reach that loop. pi records a throw from a session listener as
599
+ * the turn's failure, so an observer closed over a replaced session's `pi`
600
+ * ("This extension ctx is stale ...") used to end a detached child with an
601
+ * empty error turn and "No output" (#145). That failure is expected after a
602
+ * reload and dropped silently. Any other throw is a real observer defect: it
603
+ * is logged once per observer and also kept from killing the run.
604
+ */
605
+ function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
606
+ if (!observer) return undefined;
607
+ let logged = false;
608
+ return (...args: A) => {
609
+ try {
610
+ observer(...args);
611
+ } catch (error) {
612
+ if (isStaleCtxError(error) || logged) return;
613
+ logged = true;
614
+ console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
615
+ }
616
+ };
617
+ }
618
+
493
619
  export class AgentManager {
494
620
  private agents = new Map<string, AgentRecord>();
495
621
  private cleanupInterval: TimerHandle;
@@ -499,6 +625,8 @@ export class AgentManager {
499
625
  private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
500
626
  private onComplete?: OnAgentComplete;
501
627
  private onStart?: OnAgentStart;
628
+ private onQueuedStop?: OnAgentQueuedStop;
629
+ private onReaped?: OnAgentReaped;
502
630
  private onCompact?: OnAgentCompact;
503
631
  private onUsage?: OnAgentUsage;
504
632
  private onSteered?: OnAgentSteered;
@@ -529,6 +657,8 @@ export class AgentManager {
529
657
 
530
658
  /** Evicted records by id, oldest first (insertion order). */
531
659
  private evictedNotes = new Map<string, EvictedNote>();
660
+ /** Evicted id -> the id its reopened conversation runs under, so the old id keeps resolving (#330). */
661
+ private successors = new Map<string, string>();
532
662
 
533
663
  /**
534
664
  * Agents waiting to start, tagged with the pool they wait on (none for a
@@ -561,11 +691,16 @@ export class AgentManager {
561
691
  onUsage?: OnAgentUsage,
562
692
  onSteered?: OnAgentSteered,
563
693
  private readonly clock: Clock = realClock,
694
+ private readonly homeDir?: string,
695
+ onQueuedStop?: OnAgentQueuedStop,
696
+ onReaped?: OnAgentReaped,
564
697
  ) {
565
698
  this.onComplete = onComplete;
566
699
  this.onStart = onStart;
567
- this.onCompact = onCompact;
568
- this.onUsage = onUsage;
700
+ this.onQueuedStop = guardObserver("queued stop", onQueuedStop);
701
+ this.onReaped = guardObserver("reaped", onReaped);
702
+ this.onCompact = guardObserver("compaction", onCompact);
703
+ this.onUsage = guardObserver("usage", onUsage);
569
704
  this.onSteered = onSteered;
570
705
  this.maxConcurrent = maxConcurrent;
571
706
  // Sweep finished records past their retention window (see cleanup()).
@@ -577,6 +712,31 @@ export class AgentManager {
577
712
  this.cleanupInterval.unref?.();
578
713
  }
579
714
 
715
+ /**
716
+ * Put a run under Plan mode's policy when its owning agent's run is (a nested
717
+ * child) or the parent session is planning right now (everything else, a
718
+ * workflow stage included). The gate object is kept, since the child's hooks
719
+ * hold it.
720
+ */
721
+ private decidePlanGate(record: AgentRecord, query: () => PlanToolPolicy | undefined): void {
722
+ const owner = record.parentAgentId === undefined ? undefined : this.agents.get(record.parentAgentId);
723
+ const policy = owner?.planGate?.policy ?? query();
724
+ record.planGate ??= {};
725
+ record.planGate.policy = policy;
726
+ if (policy || record.invocation?.planMode) {
727
+ record.invocation ??= {};
728
+ record.invocation.planMode = policy ? true : undefined;
729
+ }
730
+ }
731
+
732
+ /** Spawns and settles, in order: `spawnSeq` / `settleSeq` on lifecycle events. */
733
+ private lifecycleSequence = 0;
734
+
735
+ /** The next lifecycle sequence number; the host stamps `settleSeq` with it at settle. */
736
+ nextSequence(): number {
737
+ return ++this.lifecycleSequence;
738
+ }
739
+
580
740
  /** Update the max concurrent background agents limit. */
581
741
  setMaxConcurrent(n: number) {
582
742
  this.maxConcurrent = Math.max(1, n);
@@ -644,7 +804,20 @@ export class AgentManager {
644
804
  // Validate before the queue branch — a queued spawn should fail at the
645
805
  // call, not minutes later at drain. Throw (not warn): programmatic callers
646
806
  // can fix and retry; the RPC layer converts throws into error envelopes.
647
- assertValidSpawnCwd(options.cwd);
807
+ assertSpawnCwd(options.cwd, ctx.cwd);
808
+ const selection = options.modelSelection ?? selectModel({
809
+ homeDir: options.homeDir ?? this.homeDir,
810
+ agentType: type,
811
+ definitionModel: getAgentConfig(type)?.model,
812
+ paramModel: options.model,
813
+ parentModel: ctx.model,
814
+ registry: ctx.modelRegistry,
815
+ cwd: options.configCwd ?? ctx.cwd,
816
+ agentLabel: getAgentConfig(type)?.displayName,
817
+ });
818
+ if (typeof selection === "string") throw new Error(selection);
819
+ if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
820
+ options = { ...options, model: selection.model, modelSelection: selection };
648
821
 
649
822
  const id = randomUUID().slice(0, 17);
650
823
  const abortController = new AbortController();
@@ -672,6 +845,7 @@ export class AgentManager {
672
845
  startedAt: this.clock.now(),
673
846
  abortController,
674
847
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
848
+ spawnSeq: this.nextSequence(),
675
849
  compactionCount: 0,
676
850
  // Raw tri-state (not coerced to a boolean): true = background, false =
677
851
  // foreground (has an inline tool-result surface), undefined = caller never
@@ -684,18 +858,27 @@ export class AgentManager {
684
858
  // `options` has stopped being the interesting object.
685
859
  blocking: options.blocking,
686
860
  invocation: options.invocation,
861
+ modelFallback: selection.fallback,
862
+ modelSelection: selection,
863
+ modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
864
+ previousModelTier: options.previousModelTier,
865
+ previousModelFallback: options.previousModelFallback,
866
+ previousId: options.previousId,
687
867
  depth: options.depth ?? 1,
688
868
  parentAgentId: options.parentAgentId,
689
869
  workflowId: options.workflowId,
690
870
  maxSubagentDepth: options.maxSubagentDepth,
691
871
  rootSessionId: options.rootSessionId,
692
872
  };
873
+ this.decidePlanGate(record, () => queryPlanPolicy(pi.events));
874
+ record.planQuery = () => queryPlanPolicy(pi.events, "restrict");
693
875
  this.agents.set(id, record);
694
876
  // After the insert, so `takenHandles()` already counts this record's own
695
877
  // handle — a spawn named after its own type gets `explore-2`, not a
696
878
  // duplicate `explore` that would make resolution ambiguous.
697
879
  if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
698
- record.alias = assignHandle(handleBase(options.name), this.takenHandles());
880
+ record.nameBase = handleBase(options.name);
881
+ record.alias = assignHandle(record.nameBase, this.takenHandles());
699
882
  }
700
883
 
701
884
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
@@ -711,6 +894,14 @@ export class AgentManager {
711
894
  this.agents.delete(id);
712
895
  throw err;
713
896
  }
897
+ try {
898
+ options.onPrepare?.(id);
899
+ } catch (err) {
900
+ this.agents.delete(id);
901
+ releaseBudget(lease);
902
+ this.drainQueue();
903
+ throw err;
904
+ }
714
905
  if (lease === undefined) {
715
906
  // Queue it — started once its pool has room and the process budget a
716
907
  // lease, whichever was missing.
@@ -719,7 +910,7 @@ export class AgentManager {
719
910
  record.status = "queued";
720
911
  // A queued record never reaches startAgent's signal wiring, so arm the
721
912
  // parent abort here or Esc could not release the position.
722
- if (!this.armQueuedAbort(id, options.signal)) return id;
913
+ if (!this.armQueuedAbort(record, options.signal)) return id;
723
914
  let release!: () => void;
724
915
  record.startGate = new Promise<void>(resolve => { release = resolve; });
725
916
  // Failure bookkeeping keys on whether an inline caller gets the throw,
@@ -749,18 +940,14 @@ export class AgentManager {
749
940
  * replaces it with its own, and `sendToBackground` must be able to take it
750
941
  * off, or the caller that armed it could stop a run it no longer owns.
751
942
  */
752
- private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
943
+ private armQueuedAbort(record: AgentRecord, signal?: AbortSignal): boolean {
753
944
  if (signal === undefined) return true;
754
945
  if (signal.aborted) {
755
- const record = this.agents.get(id);
756
- if (record) {
757
- record.status = "stopped";
758
- record.completedAt = this.clock.now();
759
- }
946
+ record.status = "stopped";
947
+ record.completedAt = this.clock.now();
760
948
  return false;
761
949
  }
762
- const record = this.agents.get(id);
763
- if (record) this.armParentAbort(record, signal);
950
+ this.armParentAbort(record, signal);
764
951
  return true;
765
952
  }
766
953
 
@@ -790,9 +977,8 @@ export class AgentManager {
790
977
  * budget's wait queue is full.
791
978
  */
792
979
  private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
793
- const depth = record.depth ?? 1;
794
- assertWithinBudgetDepth(depth);
795
- const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(depth) : undefined;
980
+ assertWithinBudgetDepth(record.depth);
981
+ const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(record.depth) : undefined;
796
982
  if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
797
983
  return lease;
798
984
  }
@@ -820,34 +1006,36 @@ export class AgentManager {
820
1006
  const left = new Promise<void>(resolve => { markLeft = resolve; });
821
1007
  const entry: QueueEntry = {
822
1008
  id,
1009
+ record,
823
1010
  pool,
824
1011
  start,
825
1012
  release: () => {
826
1013
  release();
827
1014
  markLeft();
828
1015
  },
829
- cancel: () => {},
1016
+ // enqueueBudget only registers; grant cannot run until the next pump,
1017
+ // after this entry has been initialized and published in the queue.
1018
+ cancel: enqueueBudget({
1019
+ depth: record.depth,
1020
+ ready: () => pool === undefined || this.poolHasRoom(pool),
1021
+ grant: (lease) => {
1022
+ this.queue = this.queue.filter(e => e !== entry);
1023
+ if (this.agents.get(id) !== record || record.status !== "queued") {
1024
+ releaseBudget(lease);
1025
+ entry.release();
1026
+ return;
1027
+ }
1028
+ record.budgetLease = lease;
1029
+ // Detached, and never rejects: a late failure (e.g. strict worktree
1030
+ // isolation) lands on the record inside `launch`. The release waits
1031
+ // for that startup to SETTLE: a release at grant time would wake a
1032
+ // blocked `spawnAndWait` while `record.promise` was still undefined,
1033
+ // and it would read a healthy agent as one that never ran.
1034
+ void entry.start().then(() => entry.release(), () => entry.release());
1035
+ },
1036
+ }),
830
1037
  left,
831
1038
  };
832
- entry.cancel = enqueueBudget({
833
- depth: record.depth ?? 1,
834
- ready: () => pool === undefined || this.poolHasRoom(pool),
835
- grant: (lease) => {
836
- this.queue = this.queue.filter(e => e !== entry);
837
- if (this.agents.get(id) !== record || record.status !== "queued") {
838
- releaseBudget(lease);
839
- entry.release();
840
- return;
841
- }
842
- record.budgetLease = lease;
843
- // Detached, and never rejects: a late failure (e.g. strict worktree
844
- // isolation) lands on the record inside `launch`. The release waits
845
- // for that startup to SETTLE: a release at grant time would wake a
846
- // blocked `spawnAndWait` while `record.promise` was still undefined,
847
- // and it would read a healthy agent as one that never ran.
848
- void entry.start().then(() => entry.release(), () => entry.release());
849
- },
850
- });
851
1039
  this.queue.push(entry);
852
1040
  }
853
1041
 
@@ -883,7 +1071,7 @@ export class AgentManager {
883
1071
  // session about it — the same failure reported twice.
884
1072
  if (failAs === "foreground") markConsumed(record, this.clock.now());
885
1073
  record.status = "error";
886
- record.error = err instanceof Error ? err.message : String(err);
1074
+ record.error = errorMessage(err);
887
1075
  record.completedAt = this.clock.now();
888
1076
  try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
889
1077
  } else {
@@ -924,10 +1112,10 @@ export class AgentManager {
924
1112
  record: AgentRecord,
925
1113
  { pi, ctx, type, prompt, options }: SpawnArgs,
926
1114
  ) {
927
- // Re-validate a caller-supplied cwd: queued spawns can start minutes after
1115
+ // Re-validate the cwd (caller-supplied, else the session's): queued spawns can start minutes after
928
1116
  // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
929
1117
  // curated errors; drainQueue parks a throw on the record as an error.
930
- assertValidSpawnCwd(options.cwd);
1118
+ assertSpawnCwd(options.cwd, ctx.cwd);
931
1119
  // Single resolution point for the caller-supplied cwd — the worktree base
932
1120
  // repo and both cleanup calls below MUST agree on this value forever.
933
1121
  const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
@@ -954,6 +1142,25 @@ export class AgentManager {
954
1142
  record.startGate = undefined;
955
1143
  this.chargePool(record.chargedPool, 1);
956
1144
 
1145
+ // Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
1146
+ try {
1147
+ if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
1148
+ const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
1149
+ // Admitting an unadmitted binding always resolves its `tier`.
1150
+ record.modelTier = tierDisclosure(options.modelSelection.tier!);
1151
+ record.modelFallback = options.modelSelection.fallback;
1152
+ if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
1153
+ if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
1154
+ }
1155
+ if (record.modelTier) {
1156
+ record.invocation ??= {};
1157
+ Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
1158
+ }
1159
+ } catch (error) {
1160
+ this.chargePool(record.chargedPool, -1);
1161
+ throw error;
1162
+ }
1163
+
957
1164
  // Worktree isolation: try to create a temporary git worktree. Strict —
958
1165
  // fail loud if not possible (no silent fallback to main tree). Done BEFORE
959
1166
  // the run is kicked off so a failure doesn't leave a half-running agent.
@@ -967,6 +1174,11 @@ export class AgentManager {
967
1174
  const isolation = options.workflowId !== undefined
968
1175
  ? options.isolation
969
1176
  : getAgentConfig(type)?.isolation ?? options.isolation;
1177
+ // A worktree exists to make edits, which a child started while planning may not.
1178
+ if (isolation === "worktree" && record.planGate?.policy) {
1179
+ this.chargePool(record.chargedPool, -1);
1180
+ throw new Error('Cannot run with isolation: "worktree" while Plan mode is active: a worktree exists to make edits. Omit `isolation`, or leave Plan mode first.');
1181
+ }
970
1182
  let worktreeCwd: string | undefined;
971
1183
  if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
972
1184
  let wt: WorktreeInfo;
@@ -974,7 +1186,7 @@ export class AgentManager {
974
1186
  wt = await createWorktree(pi, baseCwd, id);
975
1187
  } catch (err) {
976
1188
  this.chargePool(record.chargedPool, -1);
977
- const cause = err instanceof Error ? err.message : String(err);
1189
+ const cause = errorMessage(err);
978
1190
  // Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
979
1191
  const hint = customCwd === undefined
980
1192
  ? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
@@ -1002,7 +1214,11 @@ export class AgentManager {
1002
1214
  // definition unchanged) worktree instead.
1003
1215
  if (record.status !== "running") {
1004
1216
  this.chargePool(record.chargedPool, -1);
1005
- record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
1217
+ recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, wt, options.description));
1218
+ if (record.worktreeResult?.error) {
1219
+ if (!record.isBackground) markConsumed(record, this.clock.now());
1220
+ try { this.onComplete?.(record); } catch { /* do not strand the lease */ }
1221
+ }
1006
1222
  // After the cleanup, not before: the copy is still being removed until here.
1007
1223
  this.releaseLease(record);
1008
1224
  this.drainQueue();
@@ -1019,8 +1235,12 @@ export class AgentManager {
1019
1235
  this.chargePool(record.chargedPool, -1);
1020
1236
  if (record.worktree) {
1021
1237
  try {
1022
- record.worktreeResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1023
- } catch { /* ignore cleanup errors */ }
1238
+ recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, record.worktree, options.description));
1239
+ } catch { /* ignore unexpected cleanup errors */ }
1240
+ }
1241
+ if (record.worktreeResult?.error) {
1242
+ const message = err instanceof Error ? err.message : String(err);
1243
+ throw new Error(`${message}\n${record.error}`);
1024
1244
  }
1025
1245
  throw err;
1026
1246
  }
@@ -1033,15 +1253,20 @@ export class AgentManager {
1033
1253
  else this.armParentAbort(record, options.signal);
1034
1254
  const detach = () => this.detachParentAbort(record);
1035
1255
 
1256
+ record.ranThisRun = record.status === "running";
1036
1257
  const promise = runAgent(ctx, type, prompt, {
1037
1258
  pi,
1259
+ clock: this.clock,
1260
+ homeDir: options.homeDir ?? this.homeDir,
1038
1261
  agentId: id,
1039
- model: options.model,
1262
+ model: options.modelSelection?.model,
1263
+ modelSelection: record.modelSelection!,
1040
1264
  maxTurns: options.maxTurns,
1041
1265
  isolated: options.isolated,
1042
1266
  inheritContext: options.inheritContext,
1043
1267
  thinkingLevel: options.thinkingLevel,
1044
1268
  structuredOutput: options.structuredOutput,
1269
+ planGate: record.planGate,
1045
1270
  resumeSessionFile: options.resumeSessionFile,
1046
1271
  nested: options.parentAgentId !== undefined,
1047
1272
  workflow: options.workflowId !== undefined,
@@ -1078,7 +1303,7 @@ export class AgentManager {
1078
1303
  nestedRuntime: {
1079
1304
  manager: this,
1080
1305
  parentAgentId: id,
1081
- depth: record.depth ?? 1,
1306
+ depth: record.depth,
1082
1307
  maxSubagentDepth: record.maxSubagentDepth,
1083
1308
  },
1084
1309
  onSessionCreated: (session) => {
@@ -1104,7 +1329,9 @@ export class AgentManager {
1104
1329
  const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
1105
1330
  Object.assign(
1106
1331
  record.invocation,
1107
- isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1332
+ record.modelTier?.kind === "routed" && record.modelTier.admitted
1333
+ ? describeSelection(options.modelSelection, ctx.modelRegistry)
1334
+ : isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1108
1335
  );
1109
1336
  // Guarded for the reason above: a session that reports no level keeps
1110
1337
  // the request rather than losing it. Overwriting unconditionally would
@@ -1117,6 +1344,9 @@ export class AgentManager {
1117
1344
  }
1118
1345
  }
1119
1346
  }
1347
+ // The transcript says it too: it is what someone opens to find out what ran.
1348
+ if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
1349
+ if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
1120
1350
  // Flush any steers that arrived before the session was ready
1121
1351
  if (record.pendingSteers?.length) {
1122
1352
  for (const msg of record.pendingSteers) {
@@ -1155,8 +1385,8 @@ export class AgentManager {
1155
1385
  } catch { /* ignore — never block cleanup */ }
1156
1386
  }
1157
1387
  const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1158
- record.worktreeResult = wtResult;
1159
- if (wtResult.hasChanges && wtResult.branch) {
1388
+ recordWorktreeCleanup(record, wtResult);
1389
+ if (!wtResult.error && wtResult.hasChanges && wtResult.branch) {
1160
1390
  // With a caller-supplied cwd the branch lives in THAT repo, not the
1161
1391
  // parent session's — say so, or the orchestrator merges in the wrong repo.
1162
1392
  // Named by its toplevel: the cwd may be a subdirectory of it.
@@ -1165,7 +1395,7 @@ export class AgentManager {
1165
1395
  // Appended to the prose only. A structured child's caller parses
1166
1396
  // `structuredJson`, which stays untouched — but `result` is also
1167
1397
  // what a human reads, so the note still belongs on it.
1168
- record.result = (record.result ?? "") +
1398
+ record.result = responseText +
1169
1399
  `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
1170
1400
  }
1171
1401
  }
@@ -1180,7 +1410,7 @@ export class AgentManager {
1180
1410
  if (record.status !== "stopped") {
1181
1411
  record.status = "error";
1182
1412
  }
1183
- record.error = err instanceof Error ? err.message : String(err);
1413
+ record.error = errorMessage(err);
1184
1414
  record.completedAt ??= this.clock.now();
1185
1415
 
1186
1416
  detach();
@@ -1195,7 +1425,7 @@ export class AgentManager {
1195
1425
  if (record.worktree) {
1196
1426
  try {
1197
1427
  const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1198
- record.worktreeResult = wtResult;
1428
+ recordWorktreeCleanup(record, wtResult);
1199
1429
  } catch { /* ignore cleanup errors */ }
1200
1430
  }
1201
1431
 
@@ -1240,16 +1470,9 @@ export class AgentManager {
1240
1470
  // Nobody waits on a settled run; a later foreground resume must not look
1241
1471
  // like a spawn `sendToBackground` could hand off.
1242
1472
  record.blocking = false;
1243
- const pool = record.chargedPool;
1244
- this.chargePool(pool, -1);
1245
- // The isBackground half reproduces the pre-pool condition exactly — a
1246
- // background settle has always drained, even for a nested child that held
1247
- // no slot — so that path is unchanged whether or not the foreground pool is
1248
- // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1249
- // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1250
- // about reachability, and matching the old condition needs no such claim.
1251
- // The lease half: a freed budget slot may start work in any manager.
1252
- const drain = record.isBackground || pool !== undefined || record.budgetLease !== undefined;
1473
+ this.chargePool(record.chargedPool, -1);
1474
+ // Every run that reaches here holds a budget lease (each start takes one),
1475
+ // so every settle frees a slot some queued start, in any manager, may need.
1253
1476
  this.releaseLease(record);
1254
1477
 
1255
1478
  try {
@@ -1262,7 +1485,7 @@ export class AgentManager {
1262
1485
  // Drained even when an unguarded `onComplete` throws: the slot and lease
1263
1486
  // are already free, and a waiter that misses this drain may be the child
1264
1487
  // a running parent is blocked on, with nothing else left to pump.
1265
- if (drain) this.drainQueue();
1488
+ this.drainQueue();
1266
1489
  }
1267
1490
  }
1268
1491
 
@@ -1313,8 +1536,7 @@ export class AgentManager {
1313
1536
  * Returns { id, record } so callers can access the agent ID.
1314
1537
  *
1315
1538
  * @param onSpawned - Called synchronously once the run is kicked off, before
1316
- * onSessionCreated fires. Use this to set record.outputFile so
1317
- * streamToOutputFile can pick it up.
1539
+ * onSessionCreated fires.
1318
1540
  */
1319
1541
  async spawnAndWait(
1320
1542
  pi: ExtensionAPI,
@@ -1362,7 +1584,7 @@ export class AgentManager {
1362
1584
  // one contract rather than letting queue pressure decide whether a strict
1363
1585
  // worktree failure throws or returns as a result.
1364
1586
  if (record.promise === undefined && record.status === "error") {
1365
- throw new Error(record.error ?? "Agent failed to start");
1587
+ throw new Error(record.error);
1366
1588
  }
1367
1589
  return { id, record };
1368
1590
  }
@@ -1428,6 +1650,9 @@ export class AgentManager {
1428
1650
  // both paths flip the status synchronously, so a second call made in the
1429
1651
  // same tick is refused too. The caller decides whether to wait or steer.
1430
1652
  if (record.status === "running" || record.status === "queued") return undefined;
1653
+ // Decided per run: a resume after the parent left Plan gets its full surface back.
1654
+ // `spawn` sets `planQuery` on every record this manager holds.
1655
+ this.decidePlanGate(record, record.planQuery!);
1431
1656
 
1432
1657
  // Background resume: settle asynchronously and notify on completion exactly
1433
1658
  // like a background spawn, returning immediately with the record still
@@ -1443,6 +1668,8 @@ export class AgentManager {
1443
1668
 
1444
1669
  record.isBackground = true;
1445
1670
  record.resultConsumed = false;
1671
+ record.noticeDelivered = undefined;
1672
+ record.ranThisRun = false;
1446
1673
  record.consumedAt = undefined;
1447
1674
  record.result = undefined;
1448
1675
  record.error = undefined;
@@ -1459,7 +1686,7 @@ export class AgentManager {
1459
1686
  if (occupiesPoolSlot(record)) this.runningBackground--;
1460
1687
  this.releaseLease(record);
1461
1688
  record.status = "error";
1462
- record.error = err instanceof Error ? err.message : String(err);
1689
+ record.error = errorMessage(err);
1463
1690
  record.completedAt = this.clock.now();
1464
1691
  this.drainQueue();
1465
1692
  throw err;
@@ -1490,6 +1717,8 @@ export class AgentManager {
1490
1717
  // its limit; the caller's signal (or `/agents` stop) releases the wait.
1491
1718
  // No pool: the foreground pool does not cover resumes (see the README).
1492
1719
  const lease = this.admit(record, undefined);
1720
+ record.ranThisRun = false;
1721
+ record.noticeDelivered = undefined;
1493
1722
  record.status = lease === undefined ? "queued" : "running";
1494
1723
  record.budgetLease = lease;
1495
1724
  record.startedAt = this.clock.now();
@@ -1532,13 +1761,17 @@ export class AgentManager {
1532
1761
  record.status = "stopped";
1533
1762
  record.completedAt = this.clock.now();
1534
1763
  }
1764
+ markConsumed(record, this.clock.now());
1535
1765
  this.detachParentAbort(record);
1536
1766
  return record;
1537
1767
  }
1538
1768
  }
1539
1769
 
1540
1770
  try {
1771
+ // Positive evidence for the reap notice: only a live signal means the prompt runs.
1772
+ record.ranThisRun = record.status === "running";
1541
1773
  this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1774
+ clock: this.clock,
1542
1775
  type: record.type,
1543
1776
  maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
1544
1777
  onTurnEnd: options?.onTurnEnd,
@@ -1565,6 +1798,8 @@ export class AgentManager {
1565
1798
  this.failResume(record, err);
1566
1799
  }
1567
1800
  this.detachParentAbort(record);
1801
+ // The caller gets the settled record inline, whatever the outcome.
1802
+ markConsumed(record, this.clock.now());
1568
1803
 
1569
1804
  // Same contract as the spawn settle paths: children spawned during the
1570
1805
  // resumed turn must not outlive it — nothing else can see or reach them.
@@ -1605,7 +1840,7 @@ export class AgentManager {
1605
1840
  private failResume(record: AgentRecord, err: unknown): void {
1606
1841
  if (record.status !== "stopped") {
1607
1842
  record.status = "error";
1608
- record.error = err instanceof Error ? err.message : String(err);
1843
+ record.error = errorMessage(err);
1609
1844
  }
1610
1845
  record.completedAt ??= this.clock.now();
1611
1846
  }
@@ -1670,7 +1905,9 @@ export class AgentManager {
1670
1905
  this.drainQueue();
1671
1906
  };
1672
1907
 
1908
+ record.ranThisRun = true;
1673
1909
  const promise = resumeAgent(record.session, prompt, {
1910
+ clock: this.clock,
1674
1911
  type: record.type,
1675
1912
  maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
1676
1913
  onTurnEnd: options.onTurnEnd,
@@ -1736,9 +1973,15 @@ export class AgentManager {
1736
1973
  return this.agents.get(id);
1737
1974
  }
1738
1975
 
1976
+ /**
1977
+ * Handles held outside this manager, e.g. by live survivors of an orphaned one, which a fresh
1978
+ * spawn must not reuse: a listener keys lifecycle events by handle, so a twin would mix the two up.
1979
+ */
1980
+ reservedHandles: () => Iterable<string> = () => [];
1981
+
1739
1982
  /** Handles already in use, so a fresh spawn can pick an unclaimed one. */
1740
1983
  private takenHandles(): Set<string> {
1741
- const taken = new Set<string>();
1984
+ const taken = new Set<string>(this.reservedHandles());
1742
1985
  for (const record of this.agents.values()) {
1743
1986
  if (record.handle) taken.add(record.handle);
1744
1987
  if (record.alias) taken.add(record.alias);
@@ -1754,23 +1997,19 @@ export class AgentManager {
1754
1997
  }
1755
1998
 
1756
1999
  /**
1757
- * Resolve an `@name` from the prompt. Matches a top-level agent's handle
1758
- * case-insensitively, preferring one that can still be steered and otherwise
1759
- * the most recently started (which is the one a resume should continue), then
1760
- * falls back to an exact agent id so `@<agentId>` works too.
2000
+ * Resolve an `@name` from the prompt. Matches a top-level agent's handle or
2001
+ * alias case-insensitively, then falls back to an exact agent id so
2002
+ * `@<agentId>` works too.
1761
2003
  */
1762
2004
  resolveMention(name: string): MentionResolution | undefined {
1763
2005
  const wanted = name.toLowerCase();
1764
- let fallback: AgentRecord | undefined;
1765
2006
  for (const record of this.agents.values()) {
1766
2007
  if (record.parentAgentId !== undefined) continue;
1767
- // Handle and alias share one namespace, so at most one agent answers a
1768
- // name and it makes no difference which of the two matched.
1769
- if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted) continue;
1770
- if (record.status === "running" || record.status === "queued") return { kind: "live", record };
1771
- if (!fallback || record.startedAt > fallback.startedAt) fallback = record;
2008
+ // Handle and alias share one lowercase namespace (`takenHandles`, and a
2009
+ // reopen reclaims the evicted record's own names), so at most one agent
2010
+ // answers a name and it makes no difference which of the two matched.
2011
+ if (record.handle === wanted || record.alias === wanted) return { kind: "live", record };
1772
2012
  }
1773
- if (fallback) return { kind: "live", record: fallback };
1774
2013
  const byId = this.agents.get(name);
1775
2014
  if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
1776
2015
  // Only once nothing live answers: a tombstone is a conversation to reopen,
@@ -1807,7 +2046,14 @@ export class AgentManager {
1807
2046
  );
1808
2047
  }
1809
2048
 
1810
- abort(id: string): boolean {
2049
+ /**
2050
+ * Stop a running or queued agent; false when it was neither.
2051
+ *
2052
+ * @param by `"parent"` when the agent that spawned it asked (`stop_subagent`);
2053
+ * omitted for a human stop. Written on every stop, so a resumed record never
2054
+ * carries an earlier run's answer.
2055
+ */
2056
+ abort(id: string, by?: AgentRecord["stoppedBy"]): boolean {
1811
2057
  const record = this.agents.get(id);
1812
2058
  if (!record) return false;
1813
2059
 
@@ -1816,20 +2062,29 @@ export class AgentManager {
1816
2062
  // done; a blocking caller learns of the stop from its own tool result.
1817
2063
  if (record.status === "queued") {
1818
2064
  this.dequeue(q => q.id === id);
1819
- record.status = "stopped";
1820
- record.completedAt = this.clock.now();
2065
+ record.stoppedBy = by;
2066
+ this.stopQueued(record);
1821
2067
  return true;
1822
2068
  }
1823
2069
 
1824
2070
  if (record.status !== "running") return false;
2071
+ // Before the abort: the run settles asynchronously and its notice reads this.
2072
+ record.stoppedBy = by;
1825
2073
  record.abortController?.abort();
1826
2074
  record.status = "stopped";
1827
2075
  record.completedAt = this.clock.now();
1828
2076
  return true;
1829
2077
  }
1830
2078
 
2079
+ /** Mark a dequeued record stopped and tell `onQueuedStop`; the queue entry is already gone. */
2080
+ private stopQueued(record: AgentRecord): void {
2081
+ record.status = "stopped";
2082
+ record.completedAt = this.clock.now();
2083
+ this.onQueuedStop?.(record);
2084
+ }
2085
+
1831
2086
  /** Dispose a record's session and remove it from the map. */
1832
- private removeRecord(id: string, record: AgentRecord): void {
2087
+ private removeRecord(id: string, record: SettledRecord): void {
1833
2088
  this.tombstone(record);
1834
2089
  this.noteEviction(record);
1835
2090
  const session = record.session;
@@ -1852,17 +2107,63 @@ export class AgentManager {
1852
2107
  * addressed by and a session file to reopen — an in-memory session leaves no
1853
2108
  * transcript, so the mention would have nothing to continue from.
1854
2109
  */
1855
- private tombstone(record: AgentRecord): void {
1856
- if (!record.handle || !record.sessionFile) return;
1857
- this.tombstones.set(record.handle, {
2110
+ private tombstone(record: SettledRecord): void {
2111
+ const entry = this.tombstoneOf(record);
2112
+ if (entry) this.storeTombstone(entry);
2113
+ }
2114
+
2115
+ /** What `@handle` needs to reopen `record`'s conversation; undefined without a handle and a session file. */
2116
+ private tombstoneOf(record: SettledRecord): AgentTombstone | undefined {
2117
+ if (!record.handle || !record.sessionFile) return undefined;
2118
+ return {
1858
2119
  handle: record.handle,
1859
2120
  alias: record.alias,
1860
2121
  id: record.id,
1861
2122
  type: record.type,
1862
2123
  description: record.description,
1863
2124
  sessionFile: record.sessionFile,
1864
- completedAt: record.completedAt ?? this.clock.now(),
1865
- });
2125
+ completedAt: record.completedAt,
2126
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
2127
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
2128
+ };
2129
+ }
2130
+
2131
+ /** Tombstones for whichever of `ids` are settled here and reopenable, to hand to a session that will never see these records. */
2132
+ evictionTombstones(ids: readonly string[]): AgentTombstone[] {
2133
+ return [...this.agents.values()]
2134
+ .filter(isSettled)
2135
+ .filter(record => isTopLevelAgent(record) && ids.includes(record.id))
2136
+ .flatMap(record => this.tombstoneOf(record) ?? []);
2137
+ }
2138
+
2139
+ /**
2140
+ * Keep `entry` reopenable by handle, alias and id. Also how an agent that settled in an
2141
+ * orphaned manager (`/new`, `/reload`) stays reopenable here (#330). A name already held
2142
+ * by another live agent, an evicted conversation or a handle reserved for another manager's
2143
+ * survivor stays with its holder (as `takenHandles` has it): two conversations must not
2144
+ * answer to one `@name`. The entry is then not kept and its note says why.
2145
+ */
2146
+ rememberTombstone(entry: AgentTombstone): void {
2147
+ const names = [entry.handle, ...(entry.alias ? [entry.alias] : [])];
2148
+ const reserved = new Set<string>(this.reservedHandles());
2149
+ const owns = (own: { id: string; handle?: string; alias?: string }, name: string) =>
2150
+ own.id !== entry.id && (name === own.handle || name === own.alias);
2151
+ const taken = names.find(
2152
+ name =>
2153
+ reserved.has(name) ||
2154
+ [...this.agents.values()].some(own => owns(own, name)) ||
2155
+ [...this.tombstones.values()].some(own => owns(own, name)),
2156
+ );
2157
+ if (taken !== undefined) {
2158
+ const note = this.evictedNotes.get(entry.id);
2159
+ if (note) note.nameTaken = taken;
2160
+ return;
2161
+ }
2162
+ this.storeTombstone(entry);
2163
+ }
2164
+
2165
+ private storeTombstone(entry: AgentTombstone): void {
2166
+ this.tombstones.set(entry.handle, entry);
1866
2167
  // Bound the memory a long session can accumulate. Oldest first, since the
1867
2168
  // agent someone still wants to reach is the one they used most recently.
1868
2169
  while (this.tombstones.size > MAX_TOMBSTONES) {
@@ -1872,24 +2173,68 @@ export class AgentManager {
1872
2173
  }
1873
2174
 
1874
2175
  /** Remember that `record` existed, so its id still answers with something better than "not found". */
1875
- private noteEviction(record: AgentRecord): void {
1876
- const completedAt = record.completedAt ?? this.clock.now();
1877
- this.evictedNotes.delete(record.id);
1878
- this.evictedNotes.set(record.id, {
2176
+ private noteEviction(record: SettledRecord): void {
2177
+ // Only the top-level tools read notes, and an owned child (nested or a
2178
+ // workflow's) must stay unreachable from them after eviction as before it.
2179
+ if (!isTopLevelAgent(record)) return;
2180
+ this.rememberEvicted(this.evictionNote(record));
2181
+ }
2182
+
2183
+ /** Notes for whichever of `ids` are settled here, to hand to a session that will never see these records. */
2184
+ evictionNotes(ids: readonly string[]): EvictedNote[] {
2185
+ return [...this.agents.values()].filter(isSettled).filter(record => isTopLevelAgent(record) && ids.includes(record.id)).map(record => this.evictionNote(record));
2186
+ }
2187
+
2188
+ /** What a settled record leaves behind once it is gone from this manager. */
2189
+ private evictionNote(record: SettledRecord): EvictedNote {
2190
+ return {
1879
2191
  id: record.id,
1880
2192
  type: record.type,
1881
2193
  description: record.description,
1882
2194
  status: record.status,
1883
- evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
2195
+ evictedAfterMin: Math.max(0, Math.round((this.clock.now() - record.completedAt) / 60_000)),
1884
2196
  transcript: record.outputFile ?? record.sessionFile,
1885
- });
1886
- while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
1887
- const oldest = this.evictedNotes.keys().next().value;
1888
- if (oldest === undefined) break;
2197
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
2198
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
2199
+ };
2200
+ }
2201
+
2202
+ /**
2203
+ * Keep `note` addressable by its id, bounded by age. Also how a result handed
2204
+ * over by an orphaned manager (`/new`, `/reload`) stays answerable here: the
2205
+ * record settled in a manager this one never saw (#330).
2206
+ */
2207
+ rememberEvicted(note: EvictedNote): void {
2208
+ this.evictedNotes.delete(note.id);
2209
+ this.evictedNotes.set(note.id, note);
2210
+ // Insertion order is age order (the note was just re-inserted above).
2211
+ for (const oldest of this.evictedNotes.keys()) {
2212
+ if (this.evictedNotes.size <= MAX_EVICTED_NOTES) break;
1889
2213
  this.evictedNotes.delete(oldest);
1890
2214
  }
1891
2215
  }
1892
2216
 
2217
+ /** Record that evicted `id` was reopened as `next`; bounded like the eviction notes. */
2218
+ linkSuccessor(id: string, next: string): void {
2219
+ this.successors.delete(id);
2220
+ this.successors.set(id, next);
2221
+ // One link per call, so at most one over the bound: drop the oldest.
2222
+ if (this.successors.size > MAX_EVICTED_NOTES) this.successors.delete(this.successors.keys().next().value as string);
2223
+ }
2224
+
2225
+ /** Forget a reopen that never started, so `id` resolves to its tombstone again. */
2226
+ unlinkSuccessor(id: string): void {
2227
+ this.successors.delete(id);
2228
+ }
2229
+
2230
+ /** The newest id `id` continues under, following every reopen; `id` itself when never reopened. */
2231
+ latestId(id: string): string {
2232
+ // Acyclic: every successor is a freshly generated id.
2233
+ let current = id;
2234
+ for (let next = this.successors.get(current); next !== undefined; next = this.successors.get(current)) current = next;
2235
+ return current;
2236
+ }
2237
+
1893
2238
  /** What became of an evicted agent, by id; undefined for an id this session never evicted. */
1894
2239
  getEvictedNote(id: string): EvictedNote | undefined {
1895
2240
  return this.evictedNotes.get(id);
@@ -1904,12 +2249,21 @@ export class AgentManager {
1904
2249
  private cleanup() {
1905
2250
  const now = this.clock.now();
1906
2251
  for (const [id, record] of this.agents) {
1907
- if (record.status === "running" || record.status === "queued") continue;
1908
- const completedAt = record.completedAt ?? 0;
2252
+ if (!isSettled(record)) continue;
2253
+ const { completedAt } = record;
1909
2254
  const expired = record.resultConsumed
1910
2255
  ? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
1911
2256
  : now - completedAt >= this.unreadTtlMs;
1912
- if (expired) this.removeRecord(id, record);
2257
+ if (!expired) continue;
2258
+ const ran = record.ranThisRun === true && (record.result !== undefined || record.error !== undefined);
2259
+ this.removeRecord(id, record);
2260
+ // Evicting a result nobody read is a loss the parent must hear about. Owned
2261
+ // children never leave a note, and only their owner could have read them.
2262
+ // Nothing was lost when the parent saw the completion notice (a failure's
2263
+ // stays unread in FleetView), or when no run produced a result (stopped in
2264
+ // the queue, refused, or failed before the runner started).
2265
+ const note = record.resultConsumed || record.noticeDelivered || !ran ? undefined : this.evictedNotes.get(id);
2266
+ if (note) this.onReaped?.(note);
1913
2267
  }
1914
2268
  }
1915
2269
 
@@ -1921,7 +2275,7 @@ export class AgentManager {
1921
2275
  */
1922
2276
  clearCompleted(skipUnconsumed = false): void {
1923
2277
  for (const [id, record] of this.agents) {
1924
- if (record.status === "running" || record.status === "queued") continue;
2278
+ if (!isSettled(record)) continue;
1925
2279
  if (skipUnconsumed && !record.resultConsumed) continue;
1926
2280
  this.removeRecord(id, record);
1927
2281
  }
@@ -1934,6 +2288,7 @@ export class AgentManager {
1934
2288
  this.tombstones.clear();
1935
2289
  // Ids are only meaningful within the session that issued them.
1936
2290
  this.evictedNotes.clear();
2291
+ this.successors.clear();
1937
2292
  }
1938
2293
 
1939
2294
  /** Whether any agents are still running or queued. */
@@ -1943,6 +2298,22 @@ export class AgentManager {
1943
2298
  );
1944
2299
  }
1945
2300
 
2301
+ /**
2302
+ * The session's own agents (see `isTopLevelAgent`) still running or queued,
2303
+ * split by whether their completion wakes the parent. A background agent's
2304
+ * settle sends a completion notice that starts a turn, in every join mode;
2305
+ * a foreground one, or an RPC spawn that omits `isBackground`, is marked
2306
+ * consumed at settle and notifies nobody. Owned children report through their owner, so counting
2307
+ * them would double-count the same work.
2308
+ */
2309
+ ownedPending(): OwnedPending {
2310
+ const pending = [...this.agents.values()].filter(
2311
+ r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"),
2312
+ );
2313
+ const notifying = pending.filter(r => r.isBackground).length;
2314
+ return { notifying, silent: pending.length - notifying };
2315
+ }
2316
+
1946
2317
  /**
1947
2318
  * Abort everything that cannot outlive its session and let the rest finish.
1948
2319
  * Survivors are running top-level background agents and their nested children:
@@ -1956,19 +2327,11 @@ export class AgentManager {
1956
2327
  * @returns how many agents keep running.
1957
2328
  */
1958
2329
  detach(): number {
1959
- const keepQueued = (id: string): boolean => {
1960
- const record = this.agents.get(id);
1961
- return record !== undefined && record.parentAgentId !== undefined && record.workflowId === undefined;
1962
- };
1963
- for (const queued of this.queue) {
1964
- if (keepQueued(queued.id)) continue;
1965
- const record = this.agents.get(queued.id);
1966
- if (record) {
1967
- record.status = "stopped";
1968
- record.completedAt = this.clock.now();
1969
- }
1970
- }
1971
- this.dequeue(entry => !keepQueued(entry.id));
2330
+ const keepQueued = ({ record }: QueueEntry): boolean =>
2331
+ record.parentAgentId !== undefined && record.workflowId === undefined;
2332
+ const dropped = this.queue.filter(entry => !keepQueued(entry)).map(({ record }) => record);
2333
+ this.dequeue(entry => !keepQueued(entry));
2334
+ for (const record of dropped) this.stopQueued(record);
1972
2335
  let survivors = 0;
1973
2336
  for (const record of this.agents.values()) {
1974
2337
  if (record.status !== "running") continue;
@@ -1988,15 +2351,12 @@ export class AgentManager {
1988
2351
  abortAll(): number {
1989
2352
  let count = 0;
1990
2353
  // Clear queued agents first
1991
- for (const queued of this.queue) {
1992
- const record = this.agents.get(queued.id);
1993
- if (record) {
1994
- record.status = "stopped";
1995
- record.completedAt = this.clock.now();
1996
- count++;
1997
- }
1998
- }
2354
+ const dropped = this.queue.map(({ record }) => record);
1999
2355
  this.dequeue(() => true);
2356
+ for (const record of dropped) {
2357
+ this.stopQueued(record);
2358
+ count++;
2359
+ }
2000
2360
  // Abort running agents
2001
2361
  for (const record of this.agents.values()) {
2002
2362
  if (record.status === "running") {