@nredd/pi-subagents 0.22.0-nredd.8 → 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 (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. package/src/worktree.ts +309 -19
@@ -21,19 +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
34
  import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
34
35
  import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
35
36
  import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
36
37
  import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
38
+ import { type PlanToolPolicy, queryPlanPolicy } from "./plan-mode-children.js";
37
39
  import {
38
40
  assertWithinBudgetDepth,
39
41
  BUDGET_QUEUE_FULL_ERROR,
@@ -49,7 +51,7 @@ import { isStaleCtxError } from "./stale-ctx.js";
49
51
  import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
50
52
  import { addUsage, type LifetimeUsage } from "./usage.js";
51
53
  import type { CompiledSchema } from "./workflow/json-schema.js";
52
- import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
54
+ import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
53
55
 
54
56
  /**
55
57
  * Relabel a record's model from the response that just finished. A virtual
@@ -70,8 +72,26 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
70
72
  }
71
73
  }
72
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";
83
+ }
84
+
73
85
  export type OnAgentComplete = (record: AgentRecord) => void;
74
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;
75
95
  /**
76
96
  * Fired by `steer()` once a message is delivered or queued. Every steer path
77
97
  * goes through it except the steer tools (`steer_subagent` in index.ts, and its
@@ -175,6 +195,8 @@ export interface EvictedNote {
175
195
  /** The run's model fallback, so even an evicted agent's id still discloses it. */
176
196
  modelFallback?: ModelFallback;
177
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;
178
200
  }
179
201
 
180
202
  /** The text a tool returns for an id whose record was evicted. */
@@ -214,6 +236,50 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
214
236
  }
215
237
  }
216
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
+
217
283
  /**
218
284
  * Whether a record occupies one of the `maxConcurrent` background slots.
219
285
  * Nested children don't: their parent already holds a slot, so counting (and
@@ -277,6 +343,8 @@ function occupiesForegroundSlot(
277
343
  /** A start waiting in `AgentManager.queue`; see there. */
278
344
  interface QueueEntry {
279
345
  id: string;
346
+ /** The queued record; it stays in `agents` while queued, since only settled records are evicted. */
347
+ record: AgentRecord;
280
348
  /** The pool it needs room in, or undefined when it waits on the budget alone. */
281
349
  pool?: Pool;
282
350
  start: () => Promise<void>;
@@ -331,6 +399,8 @@ interface SpawnOptions {
331
399
  previousModelTier?: TierDisclosure;
332
400
  /** Historical fallback only, never used to label the continuation. */
333
401
  previousModelFallback?: ModelFallback;
402
+ /** Internal, like `reclaim`: the evicted id a reopen continues. */
403
+ previousId?: string;
334
404
 
335
405
  maxTurns?: number;
336
406
  isolated?: boolean;
@@ -401,9 +471,11 @@ interface SpawnOptions {
401
471
  invocation?: AgentInvocation;
402
472
  /** Parent abort signal — when aborted, the subagent is also stopped. */
403
473
  signal?: AbortSignal;
474
+ /** Prepare the record before it can be queued or started. A throw rejects the spawn. */
475
+ onPrepare?: (id: string) => void;
404
476
  /**
405
477
  * Called synchronously once the record is in the map and its promise is set,
406
- * before `onSessionCreated` fires — where callers attach the output file.
478
+ * before `onSessionCreated` fires.
407
479
  *
408
480
  * Carried on the options rather than parked on the manager for the duration
409
481
  * of a spawn: with a foreground queue, `startAgent` can run at drain time,
@@ -553,6 +625,8 @@ export class AgentManager {
553
625
  private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
554
626
  private onComplete?: OnAgentComplete;
555
627
  private onStart?: OnAgentStart;
628
+ private onQueuedStop?: OnAgentQueuedStop;
629
+ private onReaped?: OnAgentReaped;
556
630
  private onCompact?: OnAgentCompact;
557
631
  private onUsage?: OnAgentUsage;
558
632
  private onSteered?: OnAgentSteered;
@@ -583,6 +657,8 @@ export class AgentManager {
583
657
 
584
658
  /** Evicted records by id, oldest first (insertion order). */
585
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>();
586
662
 
587
663
  /**
588
664
  * Agents waiting to start, tagged with the pool they wait on (none for a
@@ -616,9 +692,13 @@ export class AgentManager {
616
692
  onSteered?: OnAgentSteered,
617
693
  private readonly clock: Clock = realClock,
618
694
  private readonly homeDir?: string,
695
+ onQueuedStop?: OnAgentQueuedStop,
696
+ onReaped?: OnAgentReaped,
619
697
  ) {
620
698
  this.onComplete = onComplete;
621
699
  this.onStart = onStart;
700
+ this.onQueuedStop = guardObserver("queued stop", onQueuedStop);
701
+ this.onReaped = guardObserver("reaped", onReaped);
622
702
  this.onCompact = guardObserver("compaction", onCompact);
623
703
  this.onUsage = guardObserver("usage", onUsage);
624
704
  this.onSteered = onSteered;
@@ -632,6 +712,31 @@ export class AgentManager {
632
712
  this.cleanupInterval.unref?.();
633
713
  }
634
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
+
635
740
  /** Update the max concurrent background agents limit. */
636
741
  setMaxConcurrent(n: number) {
637
742
  this.maxConcurrent = Math.max(1, n);
@@ -699,7 +804,7 @@ export class AgentManager {
699
804
  // Validate before the queue branch — a queued spawn should fail at the
700
805
  // call, not minutes later at drain. Throw (not warn): programmatic callers
701
806
  // can fix and retry; the RPC layer converts throws into error envelopes.
702
- assertValidSpawnCwd(options.cwd);
807
+ assertSpawnCwd(options.cwd, ctx.cwd);
703
808
  const selection = options.modelSelection ?? selectModel({
704
809
  homeDir: options.homeDir ?? this.homeDir,
705
810
  agentType: type,
@@ -740,6 +845,7 @@ export class AgentManager {
740
845
  startedAt: this.clock.now(),
741
846
  abortController,
742
847
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
848
+ spawnSeq: this.nextSequence(),
743
849
  compactionCount: 0,
744
850
  // Raw tri-state (not coerced to a boolean): true = background, false =
745
851
  // foreground (has an inline tool-result surface), undefined = caller never
@@ -757,18 +863,22 @@ export class AgentManager {
757
863
  modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
758
864
  previousModelTier: options.previousModelTier,
759
865
  previousModelFallback: options.previousModelFallback,
866
+ previousId: options.previousId,
760
867
  depth: options.depth ?? 1,
761
868
  parentAgentId: options.parentAgentId,
762
869
  workflowId: options.workflowId,
763
870
  maxSubagentDepth: options.maxSubagentDepth,
764
871
  rootSessionId: options.rootSessionId,
765
872
  };
873
+ this.decidePlanGate(record, () => queryPlanPolicy(pi.events));
874
+ record.planQuery = () => queryPlanPolicy(pi.events, "restrict");
766
875
  this.agents.set(id, record);
767
876
  // After the insert, so `takenHandles()` already counts this record's own
768
877
  // handle — a spawn named after its own type gets `explore-2`, not a
769
878
  // duplicate `explore` that would make resolution ambiguous.
770
879
  if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
771
- record.alias = assignHandle(handleBase(options.name), this.takenHandles());
880
+ record.nameBase = handleBase(options.name);
881
+ record.alias = assignHandle(record.nameBase, this.takenHandles());
772
882
  }
773
883
 
774
884
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
@@ -784,6 +894,14 @@ export class AgentManager {
784
894
  this.agents.delete(id);
785
895
  throw err;
786
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
+ }
787
905
  if (lease === undefined) {
788
906
  // Queue it — started once its pool has room and the process budget a
789
907
  // lease, whichever was missing.
@@ -792,7 +910,7 @@ export class AgentManager {
792
910
  record.status = "queued";
793
911
  // A queued record never reaches startAgent's signal wiring, so arm the
794
912
  // parent abort here or Esc could not release the position.
795
- if (!this.armQueuedAbort(id, options.signal)) return id;
913
+ if (!this.armQueuedAbort(record, options.signal)) return id;
796
914
  let release!: () => void;
797
915
  record.startGate = new Promise<void>(resolve => { release = resolve; });
798
916
  // Failure bookkeeping keys on whether an inline caller gets the throw,
@@ -822,18 +940,14 @@ export class AgentManager {
822
940
  * replaces it with its own, and `sendToBackground` must be able to take it
823
941
  * off, or the caller that armed it could stop a run it no longer owns.
824
942
  */
825
- private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
943
+ private armQueuedAbort(record: AgentRecord, signal?: AbortSignal): boolean {
826
944
  if (signal === undefined) return true;
827
945
  if (signal.aborted) {
828
- const record = this.agents.get(id);
829
- if (record) {
830
- record.status = "stopped";
831
- record.completedAt = this.clock.now();
832
- }
946
+ record.status = "stopped";
947
+ record.completedAt = this.clock.now();
833
948
  return false;
834
949
  }
835
- const record = this.agents.get(id);
836
- if (record) this.armParentAbort(record, signal);
950
+ this.armParentAbort(record, signal);
837
951
  return true;
838
952
  }
839
953
 
@@ -863,9 +977,8 @@ export class AgentManager {
863
977
  * budget's wait queue is full.
864
978
  */
865
979
  private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
866
- const depth = record.depth ?? 1;
867
- assertWithinBudgetDepth(depth);
868
- 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;
869
982
  if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
870
983
  return lease;
871
984
  }
@@ -893,6 +1006,7 @@ export class AgentManager {
893
1006
  const left = new Promise<void>(resolve => { markLeft = resolve; });
894
1007
  const entry: QueueEntry = {
895
1008
  id,
1009
+ record,
896
1010
  pool,
897
1011
  start,
898
1012
  release: () => {
@@ -902,7 +1016,7 @@ export class AgentManager {
902
1016
  // enqueueBudget only registers; grant cannot run until the next pump,
903
1017
  // after this entry has been initialized and published in the queue.
904
1018
  cancel: enqueueBudget({
905
- depth: record.depth ?? 1,
1019
+ depth: record.depth,
906
1020
  ready: () => pool === undefined || this.poolHasRoom(pool),
907
1021
  grant: (lease) => {
908
1022
  this.queue = this.queue.filter(e => e !== entry);
@@ -957,7 +1071,7 @@ export class AgentManager {
957
1071
  // session about it — the same failure reported twice.
958
1072
  if (failAs === "foreground") markConsumed(record, this.clock.now());
959
1073
  record.status = "error";
960
- record.error = err instanceof Error ? err.message : String(err);
1074
+ record.error = errorMessage(err);
961
1075
  record.completedAt = this.clock.now();
962
1076
  try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
963
1077
  } else {
@@ -998,10 +1112,10 @@ export class AgentManager {
998
1112
  record: AgentRecord,
999
1113
  { pi, ctx, type, prompt, options }: SpawnArgs,
1000
1114
  ) {
1001
- // 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
1002
1116
  // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
1003
1117
  // curated errors; drainQueue parks a throw on the record as an error.
1004
- assertValidSpawnCwd(options.cwd);
1118
+ assertSpawnCwd(options.cwd, ctx.cwd);
1005
1119
  // Single resolution point for the caller-supplied cwd — the worktree base
1006
1120
  // repo and both cleanup calls below MUST agree on this value forever.
1007
1121
  const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
@@ -1032,7 +1146,8 @@ export class AgentManager {
1032
1146
  try {
1033
1147
  if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
1034
1148
  const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
1035
- record.modelTier = options.modelSelection.tier ? tierDisclosure(options.modelSelection.tier) : undefined;
1149
+ // Admitting an unadmitted binding always resolves its `tier`.
1150
+ record.modelTier = tierDisclosure(options.modelSelection.tier!);
1036
1151
  record.modelFallback = options.modelSelection.fallback;
1037
1152
  if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
1038
1153
  if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
@@ -1059,6 +1174,11 @@ export class AgentManager {
1059
1174
  const isolation = options.workflowId !== undefined
1060
1175
  ? options.isolation
1061
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
+ }
1062
1182
  let worktreeCwd: string | undefined;
1063
1183
  if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
1064
1184
  let wt: WorktreeInfo;
@@ -1066,7 +1186,7 @@ export class AgentManager {
1066
1186
  wt = await createWorktree(pi, baseCwd, id);
1067
1187
  } catch (err) {
1068
1188
  this.chargePool(record.chargedPool, -1);
1069
- const cause = err instanceof Error ? err.message : String(err);
1189
+ const cause = errorMessage(err);
1070
1190
  // Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
1071
1191
  const hint = customCwd === undefined
1072
1192
  ? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
@@ -1094,7 +1214,11 @@ export class AgentManager {
1094
1214
  // definition unchanged) worktree instead.
1095
1215
  if (record.status !== "running") {
1096
1216
  this.chargePool(record.chargedPool, -1);
1097
- 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
+ }
1098
1222
  // After the cleanup, not before: the copy is still being removed until here.
1099
1223
  this.releaseLease(record);
1100
1224
  this.drainQueue();
@@ -1111,8 +1235,12 @@ export class AgentManager {
1111
1235
  this.chargePool(record.chargedPool, -1);
1112
1236
  if (record.worktree) {
1113
1237
  try {
1114
- record.worktreeResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1115
- } 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}`);
1116
1244
  }
1117
1245
  throw err;
1118
1246
  }
@@ -1125,8 +1253,10 @@ export class AgentManager {
1125
1253
  else this.armParentAbort(record, options.signal);
1126
1254
  const detach = () => this.detachParentAbort(record);
1127
1255
 
1256
+ record.ranThisRun = record.status === "running";
1128
1257
  const promise = runAgent(ctx, type, prompt, {
1129
1258
  pi,
1259
+ clock: this.clock,
1130
1260
  homeDir: options.homeDir ?? this.homeDir,
1131
1261
  agentId: id,
1132
1262
  model: options.modelSelection?.model,
@@ -1136,6 +1266,7 @@ export class AgentManager {
1136
1266
  inheritContext: options.inheritContext,
1137
1267
  thinkingLevel: options.thinkingLevel,
1138
1268
  structuredOutput: options.structuredOutput,
1269
+ planGate: record.planGate,
1139
1270
  resumeSessionFile: options.resumeSessionFile,
1140
1271
  nested: options.parentAgentId !== undefined,
1141
1272
  workflow: options.workflowId !== undefined,
@@ -1172,7 +1303,7 @@ export class AgentManager {
1172
1303
  nestedRuntime: {
1173
1304
  manager: this,
1174
1305
  parentAgentId: id,
1175
- depth: record.depth ?? 1,
1306
+ depth: record.depth,
1176
1307
  maxSubagentDepth: record.maxSubagentDepth,
1177
1308
  },
1178
1309
  onSessionCreated: (session) => {
@@ -1254,8 +1385,8 @@ export class AgentManager {
1254
1385
  } catch { /* ignore — never block cleanup */ }
1255
1386
  }
1256
1387
  const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1257
- record.worktreeResult = wtResult;
1258
- if (wtResult.hasChanges && wtResult.branch) {
1388
+ recordWorktreeCleanup(record, wtResult);
1389
+ if (!wtResult.error && wtResult.hasChanges && wtResult.branch) {
1259
1390
  // With a caller-supplied cwd the branch lives in THAT repo, not the
1260
1391
  // parent session's — say so, or the orchestrator merges in the wrong repo.
1261
1392
  // Named by its toplevel: the cwd may be a subdirectory of it.
@@ -1264,7 +1395,7 @@ export class AgentManager {
1264
1395
  // Appended to the prose only. A structured child's caller parses
1265
1396
  // `structuredJson`, which stays untouched — but `result` is also
1266
1397
  // what a human reads, so the note still belongs on it.
1267
- record.result = (record.result ?? "") +
1398
+ record.result = responseText +
1268
1399
  `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
1269
1400
  }
1270
1401
  }
@@ -1279,7 +1410,7 @@ export class AgentManager {
1279
1410
  if (record.status !== "stopped") {
1280
1411
  record.status = "error";
1281
1412
  }
1282
- record.error = err instanceof Error ? err.message : String(err);
1413
+ record.error = errorMessage(err);
1283
1414
  record.completedAt ??= this.clock.now();
1284
1415
 
1285
1416
  detach();
@@ -1294,7 +1425,7 @@ export class AgentManager {
1294
1425
  if (record.worktree) {
1295
1426
  try {
1296
1427
  const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1297
- record.worktreeResult = wtResult;
1428
+ recordWorktreeCleanup(record, wtResult);
1298
1429
  } catch { /* ignore cleanup errors */ }
1299
1430
  }
1300
1431
 
@@ -1339,16 +1470,9 @@ export class AgentManager {
1339
1470
  // Nobody waits on a settled run; a later foreground resume must not look
1340
1471
  // like a spawn `sendToBackground` could hand off.
1341
1472
  record.blocking = false;
1342
- const pool = record.chargedPool;
1343
- this.chargePool(pool, -1);
1344
- // The isBackground half reproduces the pre-pool condition exactly — a
1345
- // background settle has always drained, even for a nested child that held
1346
- // no slot — so that path is unchanged whether or not the foreground pool is
1347
- // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1348
- // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1349
- // about reachability, and matching the old condition needs no such claim.
1350
- // The lease half: a freed budget slot may start work in any manager.
1351
- 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.
1352
1476
  this.releaseLease(record);
1353
1477
 
1354
1478
  try {
@@ -1361,7 +1485,7 @@ export class AgentManager {
1361
1485
  // Drained even when an unguarded `onComplete` throws: the slot and lease
1362
1486
  // are already free, and a waiter that misses this drain may be the child
1363
1487
  // a running parent is blocked on, with nothing else left to pump.
1364
- if (drain) this.drainQueue();
1488
+ this.drainQueue();
1365
1489
  }
1366
1490
  }
1367
1491
 
@@ -1412,8 +1536,7 @@ export class AgentManager {
1412
1536
  * Returns { id, record } so callers can access the agent ID.
1413
1537
  *
1414
1538
  * @param onSpawned - Called synchronously once the run is kicked off, before
1415
- * onSessionCreated fires. Use this to set record.outputFile so
1416
- * streamToOutputFile can pick it up.
1539
+ * onSessionCreated fires.
1417
1540
  */
1418
1541
  async spawnAndWait(
1419
1542
  pi: ExtensionAPI,
@@ -1461,7 +1584,7 @@ export class AgentManager {
1461
1584
  // one contract rather than letting queue pressure decide whether a strict
1462
1585
  // worktree failure throws or returns as a result.
1463
1586
  if (record.promise === undefined && record.status === "error") {
1464
- throw new Error(record.error ?? "Agent failed to start");
1587
+ throw new Error(record.error);
1465
1588
  }
1466
1589
  return { id, record };
1467
1590
  }
@@ -1527,6 +1650,9 @@ export class AgentManager {
1527
1650
  // both paths flip the status synchronously, so a second call made in the
1528
1651
  // same tick is refused too. The caller decides whether to wait or steer.
1529
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!);
1530
1656
 
1531
1657
  // Background resume: settle asynchronously and notify on completion exactly
1532
1658
  // like a background spawn, returning immediately with the record still
@@ -1542,6 +1668,8 @@ export class AgentManager {
1542
1668
 
1543
1669
  record.isBackground = true;
1544
1670
  record.resultConsumed = false;
1671
+ record.noticeDelivered = undefined;
1672
+ record.ranThisRun = false;
1545
1673
  record.consumedAt = undefined;
1546
1674
  record.result = undefined;
1547
1675
  record.error = undefined;
@@ -1558,7 +1686,7 @@ export class AgentManager {
1558
1686
  if (occupiesPoolSlot(record)) this.runningBackground--;
1559
1687
  this.releaseLease(record);
1560
1688
  record.status = "error";
1561
- record.error = err instanceof Error ? err.message : String(err);
1689
+ record.error = errorMessage(err);
1562
1690
  record.completedAt = this.clock.now();
1563
1691
  this.drainQueue();
1564
1692
  throw err;
@@ -1589,6 +1717,8 @@ export class AgentManager {
1589
1717
  // its limit; the caller's signal (or `/agents` stop) releases the wait.
1590
1718
  // No pool: the foreground pool does not cover resumes (see the README).
1591
1719
  const lease = this.admit(record, undefined);
1720
+ record.ranThisRun = false;
1721
+ record.noticeDelivered = undefined;
1592
1722
  record.status = lease === undefined ? "queued" : "running";
1593
1723
  record.budgetLease = lease;
1594
1724
  record.startedAt = this.clock.now();
@@ -1631,13 +1761,17 @@ export class AgentManager {
1631
1761
  record.status = "stopped";
1632
1762
  record.completedAt = this.clock.now();
1633
1763
  }
1764
+ markConsumed(record, this.clock.now());
1634
1765
  this.detachParentAbort(record);
1635
1766
  return record;
1636
1767
  }
1637
1768
  }
1638
1769
 
1639
1770
  try {
1771
+ // Positive evidence for the reap notice: only a live signal means the prompt runs.
1772
+ record.ranThisRun = record.status === "running";
1640
1773
  this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1774
+ clock: this.clock,
1641
1775
  type: record.type,
1642
1776
  maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
1643
1777
  onTurnEnd: options?.onTurnEnd,
@@ -1664,6 +1798,8 @@ export class AgentManager {
1664
1798
  this.failResume(record, err);
1665
1799
  }
1666
1800
  this.detachParentAbort(record);
1801
+ // The caller gets the settled record inline, whatever the outcome.
1802
+ markConsumed(record, this.clock.now());
1667
1803
 
1668
1804
  // Same contract as the spawn settle paths: children spawned during the
1669
1805
  // resumed turn must not outlive it — nothing else can see or reach them.
@@ -1704,7 +1840,7 @@ export class AgentManager {
1704
1840
  private failResume(record: AgentRecord, err: unknown): void {
1705
1841
  if (record.status !== "stopped") {
1706
1842
  record.status = "error";
1707
- record.error = err instanceof Error ? err.message : String(err);
1843
+ record.error = errorMessage(err);
1708
1844
  }
1709
1845
  record.completedAt ??= this.clock.now();
1710
1846
  }
@@ -1769,7 +1905,9 @@ export class AgentManager {
1769
1905
  this.drainQueue();
1770
1906
  };
1771
1907
 
1908
+ record.ranThisRun = true;
1772
1909
  const promise = resumeAgent(record.session, prompt, {
1910
+ clock: this.clock,
1773
1911
  type: record.type,
1774
1912
  maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
1775
1913
  onTurnEnd: options.onTurnEnd,
@@ -1835,9 +1973,15 @@ export class AgentManager {
1835
1973
  return this.agents.get(id);
1836
1974
  }
1837
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
+
1838
1982
  /** Handles already in use, so a fresh spawn can pick an unclaimed one. */
1839
1983
  private takenHandles(): Set<string> {
1840
- const taken = new Set<string>();
1984
+ const taken = new Set<string>(this.reservedHandles());
1841
1985
  for (const record of this.agents.values()) {
1842
1986
  if (record.handle) taken.add(record.handle);
1843
1987
  if (record.alias) taken.add(record.alias);
@@ -1853,23 +1997,19 @@ export class AgentManager {
1853
1997
  }
1854
1998
 
1855
1999
  /**
1856
- * Resolve an `@name` from the prompt. Matches a top-level agent's handle
1857
- * case-insensitively, preferring one that can still be steered and otherwise
1858
- * the most recently started (which is the one a resume should continue), then
1859
- * 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.
1860
2003
  */
1861
2004
  resolveMention(name: string): MentionResolution | undefined {
1862
2005
  const wanted = name.toLowerCase();
1863
- let fallback: AgentRecord | undefined;
1864
2006
  for (const record of this.agents.values()) {
1865
2007
  if (record.parentAgentId !== undefined) continue;
1866
- // Handle and alias share one namespace, so at most one agent answers a
1867
- // name and it makes no difference which of the two matched.
1868
- if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted) continue;
1869
- if (record.status === "running" || record.status === "queued") return { kind: "live", record };
1870
- 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 };
1871
2012
  }
1872
- if (fallback) return { kind: "live", record: fallback };
1873
2013
  const byId = this.agents.get(name);
1874
2014
  if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
1875
2015
  // Only once nothing live answers: a tombstone is a conversation to reopen,
@@ -1906,7 +2046,14 @@ export class AgentManager {
1906
2046
  );
1907
2047
  }
1908
2048
 
1909
- 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 {
1910
2057
  const record = this.agents.get(id);
1911
2058
  if (!record) return false;
1912
2059
 
@@ -1915,20 +2062,29 @@ export class AgentManager {
1915
2062
  // done; a blocking caller learns of the stop from its own tool result.
1916
2063
  if (record.status === "queued") {
1917
2064
  this.dequeue(q => q.id === id);
1918
- record.status = "stopped";
1919
- record.completedAt = this.clock.now();
2065
+ record.stoppedBy = by;
2066
+ this.stopQueued(record);
1920
2067
  return true;
1921
2068
  }
1922
2069
 
1923
2070
  if (record.status !== "running") return false;
2071
+ // Before the abort: the run settles asynchronously and its notice reads this.
2072
+ record.stoppedBy = by;
1924
2073
  record.abortController?.abort();
1925
2074
  record.status = "stopped";
1926
2075
  record.completedAt = this.clock.now();
1927
2076
  return true;
1928
2077
  }
1929
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
+
1930
2086
  /** Dispose a record's session and remove it from the map. */
1931
- private removeRecord(id: string, record: AgentRecord): void {
2087
+ private removeRecord(id: string, record: SettledRecord): void {
1932
2088
  this.tombstone(record);
1933
2089
  this.noteEviction(record);
1934
2090
  const session = record.session;
@@ -1951,19 +2107,63 @@ export class AgentManager {
1951
2107
  * addressed by and a session file to reopen — an in-memory session leaves no
1952
2108
  * transcript, so the mention would have nothing to continue from.
1953
2109
  */
1954
- private tombstone(record: AgentRecord): void {
1955
- if (!record.handle || !record.sessionFile) return;
1956
- 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 {
1957
2119
  handle: record.handle,
1958
2120
  alias: record.alias,
1959
2121
  id: record.id,
1960
2122
  type: record.type,
1961
2123
  description: record.description,
1962
2124
  sessionFile: record.sessionFile,
1963
- completedAt: record.completedAt ?? this.clock.now(),
2125
+ completedAt: record.completedAt,
1964
2126
  ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1965
2127
  ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1966
- });
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);
1967
2167
  // Bound the memory a long session can accumulate. Oldest first, since the
1968
2168
  // agent someone still wants to reach is the one they used most recently.
1969
2169
  while (this.tombstones.size > MAX_TOMBSTONES) {
@@ -1973,26 +2173,68 @@ export class AgentManager {
1973
2173
  }
1974
2174
 
1975
2175
  /** Remember that `record` existed, so its id still answers with something better than "not found". */
1976
- private noteEviction(record: AgentRecord): void {
1977
- const completedAt = record.completedAt ?? this.clock.now();
1978
- this.evictedNotes.delete(record.id);
1979
- 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 {
1980
2191
  id: record.id,
1981
2192
  type: record.type,
1982
2193
  description: record.description,
1983
2194
  status: record.status,
1984
- 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)),
1985
2196
  transcript: record.outputFile ?? record.sessionFile,
1986
2197
  ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1987
2198
  ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1988
- });
1989
- while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
1990
- const oldest = this.evictedNotes.keys().next().value;
1991
- if (oldest === undefined) break;
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;
1992
2213
  this.evictedNotes.delete(oldest);
1993
2214
  }
1994
2215
  }
1995
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
+
1996
2238
  /** What became of an evicted agent, by id; undefined for an id this session never evicted. */
1997
2239
  getEvictedNote(id: string): EvictedNote | undefined {
1998
2240
  return this.evictedNotes.get(id);
@@ -2007,12 +2249,21 @@ export class AgentManager {
2007
2249
  private cleanup() {
2008
2250
  const now = this.clock.now();
2009
2251
  for (const [id, record] of this.agents) {
2010
- if (record.status === "running" || record.status === "queued") continue;
2011
- const completedAt = record.completedAt ?? 0;
2252
+ if (!isSettled(record)) continue;
2253
+ const { completedAt } = record;
2012
2254
  const expired = record.resultConsumed
2013
2255
  ? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
2014
2256
  : now - completedAt >= this.unreadTtlMs;
2015
- 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);
2016
2267
  }
2017
2268
  }
2018
2269
 
@@ -2024,7 +2275,7 @@ export class AgentManager {
2024
2275
  */
2025
2276
  clearCompleted(skipUnconsumed = false): void {
2026
2277
  for (const [id, record] of this.agents) {
2027
- if (record.status === "running" || record.status === "queued") continue;
2278
+ if (!isSettled(record)) continue;
2028
2279
  if (skipUnconsumed && !record.resultConsumed) continue;
2029
2280
  this.removeRecord(id, record);
2030
2281
  }
@@ -2037,6 +2288,7 @@ export class AgentManager {
2037
2288
  this.tombstones.clear();
2038
2289
  // Ids are only meaningful within the session that issued them.
2039
2290
  this.evictedNotes.clear();
2291
+ this.successors.clear();
2040
2292
  }
2041
2293
 
2042
2294
  /** Whether any agents are still running or queued. */
@@ -2046,6 +2298,22 @@ export class AgentManager {
2046
2298
  );
2047
2299
  }
2048
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
+
2049
2317
  /**
2050
2318
  * Abort everything that cannot outlive its session and let the rest finish.
2051
2319
  * Survivors are running top-level background agents and their nested children:
@@ -2059,19 +2327,11 @@ export class AgentManager {
2059
2327
  * @returns how many agents keep running.
2060
2328
  */
2061
2329
  detach(): number {
2062
- const keepQueued = (id: string): boolean => {
2063
- const record = this.agents.get(id);
2064
- return record !== undefined && record.parentAgentId !== undefined && record.workflowId === undefined;
2065
- };
2066
- for (const queued of this.queue) {
2067
- if (keepQueued(queued.id)) continue;
2068
- const record = this.agents.get(queued.id);
2069
- if (record) {
2070
- record.status = "stopped";
2071
- record.completedAt = this.clock.now();
2072
- }
2073
- }
2074
- 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);
2075
2335
  let survivors = 0;
2076
2336
  for (const record of this.agents.values()) {
2077
2337
  if (record.status !== "running") continue;
@@ -2091,15 +2351,12 @@ export class AgentManager {
2091
2351
  abortAll(): number {
2092
2352
  let count = 0;
2093
2353
  // Clear queued agents first
2094
- for (const queued of this.queue) {
2095
- const record = this.agents.get(queued.id);
2096
- if (record) {
2097
- record.status = "stopped";
2098
- record.completedAt = this.clock.now();
2099
- count++;
2100
- }
2101
- }
2354
+ const dropped = this.queue.map(({ record }) => record);
2102
2355
  this.dequeue(() => true);
2356
+ for (const record of dropped) {
2357
+ this.stopQueued(record);
2358
+ count++;
2359
+ }
2103
2360
  // Abort running agents
2104
2361
  for (const record of this.agents.values()) {
2105
2362
  if (record.status === "running") {