@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.3

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 (66) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +125 -7
  3. package/dist/agent-manager.d.ts +119 -9
  4. package/dist/agent-manager.js +263 -56
  5. package/dist/agent-runner.d.ts +11 -0
  6. package/dist/agent-runner.js +96 -22
  7. package/dist/completion-outbox.d.ts +65 -0
  8. package/dist/completion-outbox.js +108 -0
  9. package/dist/cross-extension-rpc.js +8 -0
  10. package/dist/env.js +3 -2
  11. package/dist/exec-tree.js +11 -2
  12. package/dist/index.d.ts +7 -1
  13. package/dist/index.js +827 -252
  14. package/dist/invocation-config.d.ts +17 -4
  15. package/dist/invocation-config.js +29 -2
  16. package/dist/nested-tools.js +10 -4
  17. package/dist/schedule.d.ts +1 -1
  18. package/dist/schedule.js +10 -4
  19. package/dist/session-handoff.d.ts +102 -0
  20. package/dist/session-handoff.js +162 -0
  21. package/dist/settings.d.ts +26 -0
  22. package/dist/settings.js +10 -0
  23. package/dist/types.d.ts +24 -2
  24. package/dist/ui/agent-widget.d.ts +4 -0
  25. package/dist/ui/agent-widget.js +5 -0
  26. package/dist/ui/conversation-viewer.d.ts +104 -3
  27. package/dist/ui/conversation-viewer.js +425 -52
  28. package/dist/ui/fleet-list.d.ts +30 -9
  29. package/dist/ui/fleet-list.js +37 -12
  30. package/dist/workflow/collisions.d.ts +3 -1
  31. package/dist/workflow/collisions.js +4 -1
  32. package/dist/workflow/host.js +30 -7
  33. package/dist/workflow/journal.d.ts +16 -8
  34. package/dist/workflow/journal.js +12 -9
  35. package/dist/workflow/runtime.d.ts +7 -1
  36. package/dist/workflow/runtime.js +38 -8
  37. package/dist/workflow/task.d.ts +11 -1
  38. package/dist/workflow/task.js +13 -1
  39. package/dist/workflow/tool-description.d.ts +9 -1
  40. package/dist/workflow/tool-description.js +21 -1
  41. package/docs/rpc.md +10 -10
  42. package/docs/workflows.md +4 -4
  43. package/examples/agent-tool-description.md +1 -3
  44. package/package.json +16 -11
  45. package/src/agent-manager.ts +288 -55
  46. package/src/agent-runner.ts +100 -21
  47. package/src/completion-outbox.ts +121 -0
  48. package/src/cross-extension-rpc.ts +8 -0
  49. package/src/env.ts +3 -2
  50. package/src/exec-tree.ts +11 -2
  51. package/src/index.ts +810 -237
  52. package/src/invocation-config.ts +44 -4
  53. package/src/nested-tools.ts +10 -4
  54. package/src/schedule.ts +10 -4
  55. package/src/session-handoff.ts +188 -0
  56. package/src/settings.ts +34 -0
  57. package/src/types.ts +24 -2
  58. package/src/ui/agent-widget.ts +8 -0
  59. package/src/ui/conversation-viewer.ts +419 -35
  60. package/src/ui/fleet-list.ts +34 -11
  61. package/src/workflow/collisions.ts +4 -1
  62. package/src/workflow/host.ts +30 -7
  63. package/src/workflow/journal.ts +18 -9
  64. package/src/workflow/runtime.ts +50 -8
  65. package/src/workflow/task.ts +14 -0
  66. package/src/workflow/tool-description.ts +27 -1
@@ -17,11 +17,30 @@ import { randomUUID } from "node:crypto";
17
17
  import { existsSync, statSync } from "node:fs";
18
18
  import { isAbsolute } from "node:path";
19
19
  import { resumeAgent, runAgent } from "./agent-runner.js";
20
+ import { getAgentConfig } from "./agent-types.js";
20
21
  import { assignHandle, handleBase } from "./mention.js";
21
22
  import { describeModel } from "./model-resolver.js";
22
23
  import { sessionFileProblem } from "./session-file.js";
24
+ import { sessionFileOf, snapshotContext } from "./session-handoff.js";
23
25
  import { addUsage } from "./usage.js";
24
26
  import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, restoreWorktree } from "./worktree.js";
27
+ /**
28
+ * This module's {@link ManagerServices}, for an instance adopting a manager
29
+ * built by another copy. Getters, so each import is read only when used.
30
+ */
31
+ export function managerServices() {
32
+ return {
33
+ get runAgent() { return runAgent; },
34
+ get resumeAgent() { return resumeAgent; },
35
+ get createWorktree() { return createWorktree; },
36
+ get cleanupWorktree() { return cleanupWorktree; },
37
+ get restoreWorktree() { return restoreWorktree; },
38
+ get isWorktreeIsolationEnabled() { return isWorktreeIsolationEnabled; },
39
+ get pruneWorktrees() { return pruneWorktrees; },
40
+ get sessionFileProblem() { return sessionFileProblem; },
41
+ get getAgentConfig() { return getAgentConfig; },
42
+ };
43
+ }
25
44
  /**
26
45
  * Default max concurrent background agents.
27
46
  *
@@ -50,6 +69,11 @@ const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
50
69
  * far above the handful anyone keeps in their head.
51
70
  */
52
71
  const MAX_TOMBSTONES = 100;
72
+ /**
73
+ * The same bound for owned children — nested agents and workflow children —
74
+ * counted apart, so a large workflow cannot push the session's own agents out.
75
+ */
76
+ const MAX_OWNED_TOMBSTONES = 200;
53
77
  /**
54
78
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
55
79
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -260,6 +284,7 @@ export function resumeStateOf(record) {
260
284
  maxSubagentDepth: record.maxSubagentDepth,
261
285
  rootSessionId: record.rootSessionId,
262
286
  model: record.invocation?.modelId,
287
+ thinking: record.invocation?.thinking,
263
288
  cwd: record.cwd,
264
289
  baseCwd: record.baseCwd,
265
290
  isolated: record.isolated,
@@ -269,16 +294,25 @@ export function resumeStateOf(record) {
269
294
  worktreeResult: record.worktreeResult ? { ...record.worktreeResult } : undefined,
270
295
  };
271
296
  }
297
+ /** Session entry a top-level agent's final record goes into (with its resume state). */
298
+ export const RECORD_ENTRY_TYPE = "subagents:record";
272
299
  /**
273
- * The resume states a session's `subagents:record` entries carry, oldest
274
- * first. Entries written before they carried one, or malformed ones, are
275
- * skipped: there is nothing to reopen them from.
300
+ * Session entry an owned child's resume state goes into: a nested agent's or a
301
+ * workflow's. Kept apart from {@link RECORD_ENTRY_TYPE}, which other extensions
302
+ * read as the session's own agents, while an owned child reports only through
303
+ * its owner. Carries the resume state alone, so it stays small.
304
+ */
305
+ export const RESUME_ENTRY_TYPE = "subagents:resume";
306
+ /**
307
+ * The resume states a session's `subagents:record` and `subagents:resume`
308
+ * entries carry, oldest first. Entries written before they carried one, or
309
+ * malformed ones, are skipped: there is nothing to reopen them from.
276
310
  */
277
311
  export function resumeStatesFromEntries(entries) {
278
312
  const out = [];
279
313
  for (const entry of entries) {
280
314
  const e = entry;
281
- if (e?.type !== "custom" || e.customType !== "subagents:record")
315
+ if (e?.type !== "custom" || (e.customType !== RECORD_ENTRY_TYPE && e.customType !== RESUME_ENTRY_TYPE))
282
316
  continue;
283
317
  const state = e.data?.resume;
284
318
  if (typeof state?.id !== "string" || typeof state.type !== "string"
@@ -288,6 +322,8 @@ export function resumeStatesFromEntries(entries) {
288
322
  }
289
323
  return out;
290
324
  }
325
+ /** The manager's own module functions, read lazily (see {@link managerServices}). */
326
+ const DEFAULT_SERVICES = managerServices();
291
327
  export class AgentManager {
292
328
  agents = new Map();
293
329
  cleanupInterval;
@@ -347,6 +383,20 @@ export class AgentManager {
347
383
  /** Pending releases, by record. */
348
384
  releaseTimers = new Map();
349
385
  sessionReleaseMs = DEFAULT_SESSION_RELEASE_MS;
386
+ /** Set by an instance that adopted this manager; see {@link ManagerServices}. */
387
+ services;
388
+ /**
389
+ * The session whose agents the top-level surfaces show (`listAgents`,
390
+ * `resolveMention`, `hasRunning`, `waitForAll`). `null`: every agent — the
391
+ * default, and the only mode before a session has started.
392
+ */
393
+ visibleOwner = null;
394
+ /**
395
+ * Arguments of spawns that have not reached `runAgent` yet (queued, or still
396
+ * creating their worktree), so a session switch can swap their context for a
397
+ * frozen copy before Pi invalidates it.
398
+ */
399
+ pendingArgs = new Map();
350
400
  constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact, onUsage) {
351
401
  this.onComplete = onComplete;
352
402
  this.onStart = onStart;
@@ -357,6 +407,42 @@ export class AgentManager {
357
407
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
358
408
  this.cleanupInterval.unref();
359
409
  }
410
+ /** Replace what the extension observes of this manager's agents (an adopting instance, or parking). */
411
+ setObservers(observers) {
412
+ this.onComplete = observers.onComplete;
413
+ this.onStart = observers.onStart;
414
+ this.onCompact = observers.onCompact;
415
+ this.onUsage = observers.onUsage;
416
+ }
417
+ /** Run new work with these module functions; see {@link ManagerServices}. */
418
+ useServices(services) {
419
+ this.services = services;
420
+ }
421
+ svc() {
422
+ return this.services ?? DEFAULT_SERVICES;
423
+ }
424
+ /**
425
+ * Show only the agents of this session (its file; undefined for one in
426
+ * memory) on the top-level surfaces, or every agent with `null`. Agents of
427
+ * other sessions keep running and are reachable by id internally — their
428
+ * nested tools, the workflow host — just not listed or addressable here.
429
+ */
430
+ setVisibleOwner(owner) {
431
+ this.visibleOwner = owner;
432
+ }
433
+ /** Whether the record belongs to the session the surfaces show. */
434
+ isVisible(record) {
435
+ return this.visibleOwner === null || record.ownerSession === this.visibleOwner;
436
+ }
437
+ /**
438
+ * Before a session switch: give every spawn that has not started its run yet
439
+ * a frozen copy of its context, since Pi invalidates the live one (every read
440
+ * throws) and the spawn reads it when it starts.
441
+ */
442
+ detachPendingContexts() {
443
+ for (const args of this.pendingArgs.values())
444
+ args.ctx = snapshotContext(args.ctx);
445
+ }
360
446
  /** Update the max concurrent background agents limit. */
361
447
  setMaxConcurrent(n) {
362
448
  this.maxConcurrent = Math.max(1, n);
@@ -478,6 +564,9 @@ export class AgentManager {
478
564
  if (run.settled)
479
565
  return;
480
566
  run.settled = true;
567
+ // Nobody waits on a settled run; a later foreground resume must not look
568
+ // like a spawn `sendToBackground` could hand off.
569
+ run.record.blocking = false;
481
570
  if (run.graceTimer)
482
571
  clearTimeout(run.graceTimer);
483
572
  if (this.runs.get(run.id) === run)
@@ -650,6 +739,11 @@ export class AgentManager {
650
739
  maxSubagentDepth: options.maxSubagentDepth,
651
740
  rootSessionId: options.rootSessionId,
652
741
  isolated: options.isolated,
742
+ // An owned child belongs where its root does: its ctx is its parent
743
+ // agent's own session, not the user's.
744
+ ownerSession: options.parentAgentId !== undefined
745
+ ? this.agents.get(options.parentAgentId)?.ownerSession
746
+ : sessionFileOf(ctx),
653
747
  };
654
748
  this.agents.set(id, record);
655
749
  // After the insert, so `takenHandles()` already counts this record's own
@@ -659,6 +753,7 @@ export class AgentManager {
659
753
  record.alias = assignHandle(handleBase(options.name), this.takenHandles());
660
754
  }
661
755
  const args = { pi, ctx, type, prompt, options };
756
+ this.pendingArgs.set(id, args);
662
757
  const pool = this.poolFor(record);
663
758
  if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
664
759
  // Queue it — started when a running agent in the same pool completes.
@@ -693,23 +788,40 @@ export class AgentManager {
693
788
  * an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
694
789
  * tool-execution timeout to bail it out.
695
790
  *
696
- * The listener is left in place when the agent starts. `startAgent` adds its
697
- * own, so both fire on a later abort, but `abort()` on an already-stopped
698
- * record is a no-op — so detaching would only be tidiness, and tidiness the
699
- * `abortAll`/`dispose` paths could not offer anyway.
791
+ * The listener is removable (`record.detachParentAbort`): `startAgent`
792
+ * replaces it with its own, and `sendToBackground` must be able to take it
793
+ * off, or the caller that armed it could stop a run it no longer owns.
700
794
  */
701
795
  armQueuedAbort(id, signal) {
702
796
  if (signal === undefined)
703
797
  return true;
798
+ const record = this.agents.get(id);
704
799
  if (signal.aborted) {
705
- const record = this.agents.get(id);
706
800
  if (record)
707
801
  this.stopQueued(record);
708
802
  return false;
709
803
  }
710
- signal.addEventListener("abort", () => this.abort(id), { once: true });
804
+ if (record)
805
+ this.armParentAbort(record, signal);
711
806
  return true;
712
807
  }
808
+ /**
809
+ * Stop `record` when its caller's `signal` aborts, replacing any listener an
810
+ * earlier stage armed — at most one is live per record, so one call to
811
+ * `detachParentAbort` cuts the run loose from its caller.
812
+ */
813
+ armParentAbort(record, signal) {
814
+ this.detachParentAbort(record);
815
+ if (signal === undefined)
816
+ return;
817
+ const onParentAbort = () => this.abort(record.id);
818
+ signal.addEventListener("abort", onParentAbort, { once: true });
819
+ record.detachParentAbort = () => signal.removeEventListener("abort", onParentAbort);
820
+ }
821
+ detachParentAbort(record) {
822
+ record.detachParentAbort?.();
823
+ record.detachParentAbort = undefined;
824
+ }
713
825
  /**
714
826
  * Kick off an agent's startup and register it under `startups`. The returned
715
827
  * promise never rejects — the failure is delivered through `awaitStartup`,
@@ -724,8 +836,9 @@ export class AgentManager {
724
836
  * handle goes back.
725
837
  */
726
838
  launch(id, record, args, queuedPool) {
727
- const startup = this.startAgent(id, record, args).then(() => { this.startups.delete(id); }, (err) => {
839
+ const startup = this.startAgent(id, record, args).then(() => { this.startups.delete(id); this.dropPendingArgs(id, args); }, (err) => {
728
840
  this.startups.delete(id);
841
+ this.dropPendingArgs(id, args);
729
842
  if (queuedPool !== undefined) {
730
843
  // Mirrors endRun: an inline caller gets this failure as a throw
731
844
  // out of spawnAndWait, so an unconsumed record would ALSO nudge the
@@ -751,6 +864,10 @@ export class AgentManager {
751
864
  // (drainQueue) that swallowed promise.
752
865
  return startup.catch(() => { });
753
866
  }
867
+ dropPendingArgs(id, args) {
868
+ if (this.pendingArgs.get(id) === args)
869
+ this.pendingArgs.delete(id);
870
+ }
754
871
  /**
755
872
  * Resolves once the agent is actually running, and rejects with the startup
756
873
  * failure (strict worktree isolation) that `spawn()` used to throw before the
@@ -764,7 +881,13 @@ export class AgentManager {
764
881
  return this.startups.get(id) ?? Promise.resolve();
765
882
  }
766
883
  /** Actually start an agent (called immediately or from queue drain). */
767
- async startAgent(id, record, { pi, ctx, type, prompt, options }) {
884
+ async startAgent(id, record, args) {
885
+ // `args.ctx` is read again where the run starts, not destructured here: a
886
+ // session switch during the worktree copy swaps it for a frozen copy.
887
+ const { pi, type, prompt, options } = args;
888
+ const ctx = args.ctx;
889
+ // The functions this run starts and settles with, fixed for its lifetime.
890
+ const svc = this.svc();
768
891
  // Re-validate a caller-supplied cwd: queued spawns can start minutes after
769
892
  // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
770
893
  // curated errors; drainQueue parks a throw on the record as an error.
@@ -772,7 +895,8 @@ export class AgentManager {
772
895
  // Single resolution point for the caller-supplied cwd — the worktree base
773
896
  // repo and both cleanup calls below MUST agree on this value forever.
774
897
  const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
775
- const baseCwd = customCwd ?? ctx.cwd;
898
+ const sessionCwd = ctx.cwd;
899
+ const baseCwd = customCwd ?? sessionCwd;
776
900
  // Take the running state — and with it the concurrency slot — BEFORE the
777
901
  // first await. Creating a worktree is an awaited git call, and drainQueue
778
902
  // reads the pool counters synchronously in a loop: incrementing after the
@@ -809,7 +933,7 @@ export class AgentManager {
809
933
  this.releaseRunSlot(run);
810
934
  this.drainQueue();
811
935
  try {
812
- record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
936
+ record.worktreeResult = await svc.cleanupWorktree(pi, baseCwd, wt, options.description);
813
937
  }
814
938
  catch { /* best effort */ }
815
939
  this.endRun(run);
@@ -820,17 +944,26 @@ export class AgentManager {
820
944
  // The project switch is enforced here as well as at the tool boundary
821
945
  // because cross-extension RPC forwards its options unvalidated — a schema
822
946
  // that omits the field can't stop a caller that never saw the schema.
947
+ // The agent file's `isolation:` is resolved here too, not by each caller:
948
+ // mentions pass none, and RPC forwards only its caller's value. Workflow
949
+ // children are the exception, as in Claude Code, whose workflow agent()
950
+ // reads only the script's option and ignores the definition's. So is an
951
+ // agent reopened from its session file: it continues where it ran, and a
952
+ // worktree it ran in comes back through `restoreWorktree` below.
953
+ const isolation = options.workflowId !== undefined || options.resumeSessionFile !== undefined
954
+ ? options.isolation
955
+ : svc.getAgentConfig(type)?.isolation ?? options.isolation;
823
956
  let worktreeCwd;
824
957
  // What an earlier run of a reopened agent left on its branch. A resumed
825
958
  // run that changes nothing keeps reporting that, not "no changes".
826
959
  const priorWorktreeResult = options.restoreWorktree?.prior;
827
960
  if (options.restoreWorktree) {
828
- if (!isWorktreeIsolationEnabled()) {
961
+ if (!svc.isWorktreeIsolationEnabled()) {
829
962
  this.discardRun(run);
830
963
  throw new Error(`Cannot resume agent ${id}: it ran in a git worktree, and worktree isolation is turned off in this project.`);
831
964
  }
832
965
  const wt = { ...options.restoreWorktree.worktree };
833
- const error = await restoreWorktree(pi, baseCwd, wt, priorWorktreeResult);
966
+ const error = await svc.restoreWorktree(pi, baseCwd, wt, priorWorktreeResult);
834
967
  if (error) {
835
968
  this.discardRun(run);
836
969
  throw new Error(`Cannot resume agent ${id} in its git worktree: ${error}. Nothing was run.`);
@@ -844,8 +977,8 @@ export class AgentManager {
844
977
  return;
845
978
  }
846
979
  }
847
- else if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
848
- const wt = await createWorktree(pi, baseCwd, id);
980
+ else if (isolation === "worktree" && svc.isWorktreeIsolationEnabled()) {
981
+ const wt = await svc.createWorktree(pi, baseCwd, id);
849
982
  if (!wt) {
850
983
  this.discardRun(run);
851
984
  throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
@@ -860,6 +993,10 @@ export class AgentManager {
860
993
  // subdirectory, silently dropping extensions/skills.
861
994
  worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
862
995
  this.worktreeRepos.add(baseCwd);
996
+ // The widget's "worktree" tag reads this; only callers that resolve the
997
+ // agent file themselves seed it.
998
+ record.invocation ??= {};
999
+ record.invocation.isolation = "worktree";
863
1000
  // No longer "running" means a stop landed while the copy was being made
864
1001
  // (abort(), abortAll()) — a window that did not exist when creation was
865
1002
  // synchronous. The record is already terminal, so launching the run would
@@ -875,22 +1012,16 @@ export class AgentManager {
875
1012
  this.onStart?.(record);
876
1013
  }
877
1014
  catch { /* a start observer must not fail the run */ }
878
- // Wire parent abort signal to stop the subagent when the parent is interrupted
879
- let detachParentSignal;
880
- if (options.signal) {
881
- // A queued spawn can start minutes after the caller handed us its signal,
882
- // by which time it may already be aborted — and `addEventListener` would
883
- // never fire, leaving a child the parent can no longer reach.
884
- if (options.signal.aborted)
885
- this.abort(id);
886
- else {
887
- const onParentAbort = () => this.abort(id);
888
- options.signal.addEventListener("abort", onParentAbort, { once: true });
889
- detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
890
- }
891
- }
892
- const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
893
- const promise = runAgent(ctx, type, prompt, {
1015
+ // Wire parent abort signal to stop the subagent when the parent is interrupted.
1016
+ // A queued spawn can start minutes after the caller handed us its signal,
1017
+ // by which time it may already be aborted — and `addEventListener` would
1018
+ // never fire, leaving a child the parent can no longer reach.
1019
+ if (options.signal?.aborted)
1020
+ this.abort(id);
1021
+ else
1022
+ this.armParentAbort(record, options.signal);
1023
+ const detach = () => this.detachParentAbort(record);
1024
+ const promise = svc.runAgent(args.ctx, type, prompt, {
894
1025
  pi,
895
1026
  agentId: id,
896
1027
  model: options.model,
@@ -911,7 +1042,7 @@ export class AgentManager {
911
1042
  // Set iff a worktree was created (see above) — names the directory the
912
1043
  // copy came from, so the prompt can tell the agent not to work there.
913
1044
  worktreeBase: worktreeCwd ? baseCwd : undefined,
914
- configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
1045
+ configCwd: options.configCwd ?? (customCwd !== undefined ? sessionCwd : undefined),
915
1046
  signal: record.abortController.signal,
916
1047
  onToolActivity: (activity) => {
917
1048
  if (activity.type === "end")
@@ -982,7 +1113,12 @@ export class AgentManager {
982
1113
  }
983
1114
  record.pendingSteers = undefined;
984
1115
  }
985
- options.onSessionCreated?.(session);
1116
+ // A caller's wiring must not fail the run (it may read a ctx a session
1117
+ // switch has since invalidated).
1118
+ try {
1119
+ options.onSessionCreated?.(session);
1120
+ }
1121
+ catch { /* ignore caller wiring errors */ }
986
1122
  },
987
1123
  })
988
1124
  .then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried, maxTurns }) => {
@@ -1029,7 +1165,7 @@ export class AgentManager {
1029
1165
  // The hook runs inside cleanup: after the child's changes are
1030
1166
  // committed and while its tree still exists; what it changes is
1031
1167
  // discarded, so a gate's output never lands on the branch.
1032
- const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description, options.onBeforeWorktreeCleanup);
1168
+ const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description, options.onBeforeWorktreeCleanup);
1033
1169
  record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
1034
1170
  // Appended to the prose only. A structured child's caller parses
1035
1171
  // `structuredJson`, which stays untouched — but `result` is also
@@ -1065,7 +1201,7 @@ export class AgentManager {
1065
1201
  // Best-effort worktree cleanup on error
1066
1202
  if (record.worktree) {
1067
1203
  try {
1068
- const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1204
+ const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1069
1205
  record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
1070
1206
  // A failed run can still have changed files: say where they went,
1071
1207
  // on the error every failure surface shows.
@@ -1094,7 +1230,7 @@ export class AgentManager {
1094
1230
  }
1095
1231
  if (record.worktree) {
1096
1232
  try {
1097
- const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1233
+ const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1098
1234
  record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
1099
1235
  const note = worktreeNote(record.worktreeResult, baseCwd, customCwd !== undefined);
1100
1236
  if (note)
@@ -1227,6 +1363,50 @@ export class AgentManager {
1227
1363
  }
1228
1364
  return { id, record };
1229
1365
  }
1366
+ /**
1367
+ * Hand a live run to the background without restarting it. The session,
1368
+ * promise, in-flight turn, transcript and worktree all continue under the
1369
+ * same id; what changes is who answers for it. The caller's parent-abort
1370
+ * listener comes off (its Esc no longer reaches the run — `abort()` and
1371
+ * `/agents` still do), the slot moves to the background pool, and the run
1372
+ * then settles through the ordinary background path: `endRun` releases the
1373
+ * run's live pool, and the settle reads the live `isBackground`, so the
1374
+ * result is delivered as a background completion.
1375
+ *
1376
+ * Releasing the caller is the caller's job — the `Agent` tool races its own
1377
+ * wake-up against `spawnAndWait`. This only refuses what it can see: a run
1378
+ * nobody is blocking on (a background or detached spawn, one already handed
1379
+ * off, or a foreground resume — `blocking` is cleared when the spawned run
1380
+ * settles), one that reports through something other than the session (a
1381
+ * nested child, a workflow's agent), and one that is stopped or queued.
1382
+ *
1383
+ * The background slot is taken even when the pool is full: suspending a
1384
+ * live run is not an option, so it runs over the limit and queued
1385
+ * background work waits for the count to drop, as with `bypassQueue`.
1386
+ */
1387
+ sendToBackground(id) {
1388
+ const record = this.agents.get(id);
1389
+ const run = this.runs.get(id);
1390
+ if (!record?.blocking || !isTopLevelAgent(record) || record.status !== "running" || !record.promise)
1391
+ return false;
1392
+ if (!run || run.settled || run.record !== record)
1393
+ return false;
1394
+ this.detachParentAbort(record);
1395
+ if (!run.slotReleased) {
1396
+ if (run.pool === "foreground")
1397
+ this.runningForeground--;
1398
+ else if (run.pool === "background")
1399
+ this.runningBackground--;
1400
+ this.runningBackground++;
1401
+ }
1402
+ run.pool = "background";
1403
+ record.blocking = false;
1404
+ record.isBackground = true;
1405
+ record.resultConsumed = false;
1406
+ // A freed foreground slot may have unblocked a queued blocking spawn.
1407
+ this.drainQueue();
1408
+ return true;
1409
+ }
1230
1410
  /**
1231
1411
  * Resume an existing agent session with a new prompt.
1232
1412
  */
@@ -1375,7 +1555,7 @@ export class AgentManager {
1375
1555
  this.abort(id);
1376
1556
  else
1377
1557
  signal?.addEventListener("abort", onParentAbort, { once: true });
1378
- const { text, failure, aborted, steered } = abortController.signal.aborted ? { text: "" } : await resumeAgent(session, prompt, {
1558
+ const { text, failure, aborted, steered } = abortController.signal.aborted ? { text: "" } : await this.svc().resumeAgent(session, prompt, {
1379
1559
  maxTurns: options?.maxTurns ?? record.maxTurns,
1380
1560
  onTurnEnd: options?.onTurnEnd,
1381
1561
  onToolActivity: (activity) => {
@@ -1445,7 +1625,7 @@ export class AgentManager {
1445
1625
  if (!pi || !ctx) {
1446
1626
  throw new Error(`Agent ${entry.id} has finished and is no longer in memory, and it cannot be reopened from here.`);
1447
1627
  }
1448
- const problem = sessionFileProblem(entry.sessionFile);
1628
+ const problem = this.svc().sessionFileProblem(entry.sessionFile);
1449
1629
  if (problem) {
1450
1630
  // Gone for good: a row that can only ever fail is worse than none.
1451
1631
  if (!existsSync(entry.sessionFile))
@@ -1481,6 +1661,7 @@ export class AgentManager {
1481
1661
  maxSubagentDepth: entry.maxSubagentDepth,
1482
1662
  rootSessionId: entry.rootSessionId,
1483
1663
  invocation: {
1664
+ ...(entry.thinking ? { thinking: entry.thinking } : {}),
1484
1665
  ...(entry.isolated ? { isolated: true } : {}),
1485
1666
  ...(entry.worktree ? { isolation: "worktree" } : {}),
1486
1667
  },
@@ -1525,13 +1706,13 @@ export class AgentManager {
1525
1706
  async prepareResume(record, options) {
1526
1707
  const worktree = record.worktree;
1527
1708
  if (worktree?.path && !existsSync(worktree.path)) {
1528
- if (!isWorktreeIsolationEnabled()) {
1709
+ if (!this.svc().isWorktreeIsolationEnabled()) {
1529
1710
  throw new Error(`Cannot resume agent ${record.id}: it ran in a git worktree, and worktree isolation is turned off in this project.`);
1530
1711
  }
1531
1712
  const pi = options?.pi ?? this.pi;
1532
1713
  const repo = record.baseCwd;
1533
1714
  const error = pi && repo
1534
- ? await restoreWorktree(pi, repo, worktree, record.worktreeResult)
1715
+ ? await this.svc().restoreWorktree(pi, repo, worktree, record.worktreeResult)
1535
1716
  : "the repository it ran in is unknown";
1536
1717
  if (error) {
1537
1718
  throw new Error(`Cannot resume agent ${record.id} in its git worktree: ${error}. Nothing was run.`);
@@ -1553,7 +1734,7 @@ export class AgentManager {
1553
1734
  return;
1554
1735
  const prior = record.worktreeResult;
1555
1736
  try {
1556
- const wtResult = await cleanupWorktree(pi, repo, record.worktree, record.description);
1737
+ const wtResult = await this.svc().cleanupWorktree(pi, repo, record.worktree, record.description);
1557
1738
  record.worktreeResult = wtResult.hasChanges || !prior ? wtResult : prior;
1558
1739
  const note = worktreeNote(record.worktreeResult, repo, record.cwd !== undefined);
1559
1740
  if (!note)
@@ -1629,7 +1810,7 @@ export class AgentManager {
1629
1810
  await windDown();
1630
1811
  this.endRun(run);
1631
1812
  };
1632
- const promise = resumeAgent(record.session, prompt, {
1813
+ const promise = this.svc().resumeAgent(record.session, prompt, {
1633
1814
  maxTurns: options.maxTurns ?? record.maxTurns,
1634
1815
  onTurnEnd: options.onTurnEnd,
1635
1816
  onToolActivity: (activity) => {
@@ -1728,7 +1909,7 @@ export class AgentManager {
1728
1909
  const wanted = name.toLowerCase();
1729
1910
  let fallback;
1730
1911
  for (const record of this.agents.values()) {
1731
- if (record.parentAgentId !== undefined)
1912
+ if (record.parentAgentId !== undefined || !this.isVisible(record))
1732
1913
  continue;
1733
1914
  // Handle and alias share one namespace, so at most one agent answers a
1734
1915
  // name and it makes no difference which of the two matched.
@@ -1742,7 +1923,7 @@ export class AgentManager {
1742
1923
  if (fallback)
1743
1924
  return { kind: "live", record: fallback };
1744
1925
  const byId = this.agents.get(name);
1745
- if (byId?.parentAgentId === undefined && byId !== undefined)
1926
+ if (byId?.parentAgentId === undefined && byId !== undefined && this.isVisible(byId))
1746
1927
  return { kind: "live", record: byId };
1747
1928
  // Only once nothing live answers: a tombstone is a conversation to reopen,
1748
1929
  // and reopening one while its record still exists would fork the session.
@@ -1780,7 +1961,12 @@ export class AgentManager {
1780
1961
  listTombstones() {
1781
1962
  return [...this.tombstones.values()].filter(isTopLevelTombstone).sort((a, b) => b.completedAt - a.completedAt);
1782
1963
  }
1964
+ /** The agents of the session the surfaces show (see {@link setVisibleOwner}), newest first. */
1783
1965
  listAgents() {
1966
+ return this.listAllAgents().filter(record => this.isVisible(record));
1967
+ }
1968
+ /** Every agent this manager holds, whichever session it belongs to, newest first. */
1969
+ listAllAgents() {
1784
1970
  return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
1785
1971
  }
1786
1972
  abort(id) {
@@ -1810,6 +1996,7 @@ export class AgentManager {
1810
1996
  * off it. A blocking caller learns of the stop from its own tool result.
1811
1997
  */
1812
1998
  stopQueued(record) {
1999
+ this.pendingArgs.delete(record.id);
1813
2000
  record.status = "stopped";
1814
2001
  record.completedAt = Date.now();
1815
2002
  if (record.isBackground === false)
@@ -1846,11 +2033,19 @@ export class AgentManager {
1846
2033
  }
1847
2034
  keepTombstone(state) {
1848
2035
  this.tombstones.set(state.id, state);
1849
- // Bound the memory a long session can accumulate. Oldest first, since the
1850
- // agent someone still wants to reach is the one they used most recently.
1851
- while (this.tombstones.size > MAX_TOMBSTONES) {
1852
- const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b));
1853
- this.tombstones.delete(oldest.id);
2036
+ // Bound the memory a long session can accumulate, per kind (top-level or
2037
+ // owned). Oldest first, since the agent someone still wants to reach is the
2038
+ // one they used most recently.
2039
+ const topLevel = isTopLevelAgent(state);
2040
+ const sameKind = [...this.tombstones.values()].filter(entry => isTopLevelAgent(entry) === topLevel);
2041
+ let excess = sameKind.length - (topLevel ? MAX_TOMBSTONES : MAX_OWNED_TOMBSTONES);
2042
+ if (excess <= 0)
2043
+ return;
2044
+ sameKind.sort((a, b) => a.completedAt - b.completedAt);
2045
+ for (const entry of sameKind) {
2046
+ if (excess-- <= 0)
2047
+ break;
2048
+ this.tombstones.delete(entry.id);
1854
2049
  }
1855
2050
  }
1856
2051
  /**
@@ -1891,6 +2086,8 @@ export class AgentManager {
1891
2086
  for (const [id, record] of this.agents) {
1892
2087
  if (this.isRunActive(id))
1893
2088
  continue;
2089
+ // Kept too: another session's agent that finished while that session was
2090
+ // closed, until it is opened again and told (or the cleanup evicts it).
1894
2091
  if (skipUnconsumed && !record.resultConsumed)
1895
2092
  continue;
1896
2093
  this.removeRecord(id, record);
@@ -1908,7 +2105,10 @@ export class AgentManager {
1908
2105
  * whose run has not settled yet, which still holds its slot.
1909
2106
  */
1910
2107
  hasRunning() {
1911
- return this.runs.size > 0 || [...this.agents.values()].some(isBusy);
2108
+ for (const run of this.runs.values())
2109
+ if (this.isVisible(run.record))
2110
+ return true;
2111
+ return [...this.agents.values()].some(record => isBusy(record) && this.isVisible(record));
1912
2112
  }
1913
2113
  /** Abort all running and queued agents immediately. */
1914
2114
  abortAll() {
@@ -1940,9 +2140,11 @@ export class AgentManager {
1940
2140
  * commit and branch that preserve the agent's changes — finishes before the
1941
2141
  * process exits instead of racing it.
1942
2142
  */
1943
- async awaitWorktreeCleanup(timeoutMs) {
2143
+ async awaitWorktreeCleanup(timeoutMs, ids) {
1944
2144
  const pending = [];
1945
2145
  for (const record of this.agents.values()) {
2146
+ if (ids !== undefined && !ids.has(record.id))
2147
+ continue;
1946
2148
  const startup = this.startups.get(record.id);
1947
2149
  if (!record.worktree && !startup)
1948
2150
  continue;
@@ -1969,6 +2171,9 @@ export class AgentManager {
1969
2171
  for (const record of this.agents.values()) {
1970
2172
  if (record.status !== "running" && record.status !== "queued")
1971
2173
  continue;
2174
+ // Another session's agents keep running; this session does not wait on them.
2175
+ if (!this.isVisible(record))
2176
+ continue;
1972
2177
  // An agent whose worktree is still being created is "running" with no
1973
2178
  // `promise` yet — without its startup the wait would return too early.
1974
2179
  const startup = this.startups.get(record.id);
@@ -1980,7 +2185,8 @@ export class AgentManager {
1980
2185
  // A stopped run still winding down holds its slot, and whatever is queued
1981
2186
  // behind it starts only once it settles (or is abandoned).
1982
2187
  for (const run of this.runs.values())
1983
- pending.push(run.done);
2188
+ if (this.isVisible(run.record))
2189
+ pending.push(run.done);
1984
2190
  if (pending.length === 0)
1985
2191
  break;
1986
2192
  await Promise.allSettled(pending);
@@ -2011,12 +2217,13 @@ export class AgentManager {
2011
2217
  const sessions = [...this.agents.values()].filter(record => !this.released.has(record)).map(record => record.session);
2012
2218
  this.agents.clear();
2013
2219
  this.startups.clear();
2220
+ this.pendingArgs.clear();
2014
2221
  if (pi) {
2015
2222
  // Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
2016
2223
  // on the shutdown path, which cannot wait for git. Started before the awaited
2017
2224
  // shutdown below rather than after it, so the git calls have that window to
2018
2225
  // finish in instead of racing the process exit that follows.
2019
- const prune = (repo) => { pruneWorktrees(pi, repo).catch(() => { }); };
2226
+ const prune = (repo) => { this.svc().pruneWorktrees(pi, repo).catch(() => { }); };
2020
2227
  prune(process.cwd());
2021
2228
  // Also prune repos that caller-supplied cwds created worktrees in — a clean
2022
2229
  // exit with in-flight agents would otherwise leave stale registrations there.