@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -184,7 +184,7 @@ import {
184
184
  } from "@gajae-code/utils";
185
185
 
186
186
  import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append-only-mode";
187
- import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
187
+ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager, type OwnerSubagentShutdownLease } from "../async";
188
188
  import { reset as resetCapabilities } from "../capability";
189
189
  import type { Rule } from "../capability/rule";
190
190
  import type { CasReceipt } from "../config/atomic-yaml-patch";
@@ -610,6 +610,8 @@ export interface AgentSessionConfig {
610
610
  discoveryMode?: "off" | "mcp-only" | "all";
611
611
  /** MCP tool names to activate for the current session when discovery mode is enabled. */
612
612
  initialSelectedMCPToolNames?: string[];
613
+ /** Keep persisted MCP names until a deferred exact catalog becomes available. */
614
+ preserveUnavailableInitialMCPToolSelection?: boolean;
613
615
  /** Built-in discoverable tool names restored for the current all-discovery session. */
614
616
  initialSelectedDiscoveredBuiltinToolNames?: string[];
615
617
  /** Discoverable built-ins active for configured or explicit reasons independently of persisted discovery selection. */
@@ -656,6 +658,8 @@ export interface AgentSessionConfig {
656
658
  * process-global manager **MUST NOT** dispose it on their own teardown.
657
659
  */
658
660
  ownedMcpManager?: MCPManager;
661
+ /** Optional startup dependency that must settle before the first provider turn. */
662
+ startupTurnBarrier?: Promise<void>;
659
663
  /** Optional fork-context seed used to initialize a child session before its first prompt. */
660
664
  forkContextSeed?: ForkContextSeed;
661
665
  /** Optional provider state override. Fork-context children should omit this by default. */
@@ -1947,6 +1951,7 @@ export class AgentSession {
1947
1951
  */
1948
1952
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
1949
1953
  readonly #ownedMcpManager: MCPManager | undefined;
1954
+ #startupTurnBarrier: Promise<void> | undefined;
1950
1955
  #pendingPythonMessages: PythonExecutionMessage[] = [];
1951
1956
  #activeEvalExecutions = new Set<Promise<unknown>>();
1952
1957
  #evalExecutionDisposing = false;
@@ -1966,6 +1971,8 @@ export class AgentSession {
1966
1971
  #isDisposed = false;
1967
1972
  #disposePromise: Promise<void> | undefined;
1968
1973
  readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
1974
+ readonly #toolSessionTransitionCleanups = new Set<() => Promise<void> | void>();
1975
+ readonly #deferredOwnerShutdownFinalizations = new Set<Promise<void>>();
1969
1976
  #newSessionTransition: Promise<boolean> | undefined;
1970
1977
  // Extension system
1971
1978
  #extensionRunner: ExtensionRunner | undefined = undefined;
@@ -2269,10 +2276,23 @@ export class AgentSession {
2269
2276
  next.ready.resolve();
2270
2277
  }
2271
2278
 
2279
+ async #awaitStartupTurnBarrier(): Promise<void> {
2280
+ const barrier = this.#startupTurnBarrier;
2281
+ if (!barrier) return;
2282
+ await barrier;
2283
+ if (this.#startupTurnBarrier === barrier) this.#startupTurnBarrier = undefined;
2284
+ }
2285
+ extendStartupTurnBarrier(barrier: Promise<void>): void {
2286
+ const current = this.#startupTurnBarrier;
2287
+ this.#startupTurnBarrier = current ? Promise.all([current, barrier]).then(() => {}) : barrier;
2288
+ void this.#startupTurnBarrier.catch(() => {});
2289
+ }
2290
+
2272
2291
  async #withSessionAdmission<T>(
2273
2292
  kind: SessionAdmissionKind,
2274
2293
  body: (lease: SessionAdmissionLease) => Promise<T>,
2275
2294
  ): Promise<T> {
2295
+ if (kind === "prompt") await this.#awaitStartupTurnBarrier();
2276
2296
  const owner = this.#sessionAdmissionContext.getStore();
2277
2297
  if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
2278
2298
  if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
@@ -2601,6 +2621,7 @@ export class AgentSession {
2601
2621
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
2602
2622
  this.#retainedMemorySampler = config.retainedMemorySampler;
2603
2623
  this.#ownedMcpManager = config.ownedMcpManager;
2624
+ this.#startupTurnBarrier = config.startupTurnBarrier;
2604
2625
  this.#scopedModels = config.scopedModels ?? [];
2605
2626
  this.#thinkingLevel = config.thinkingLevel;
2606
2627
  this.#promptTemplates = config.promptTemplates ?? [];
@@ -2661,6 +2682,8 @@ export class AgentSession {
2661
2682
  injectIdle: async messages => {
2662
2683
  const first = messages[0];
2663
2684
  if (!first) return;
2685
+ await this.#awaitStartupTurnBarrier();
2686
+ if (this.#isDisposed) return;
2664
2687
  if (messages.length === 1) {
2665
2688
  await this.agent.prompt(first, this.#managedFallbackPromptOptions());
2666
2689
  } else {
@@ -2723,7 +2746,7 @@ export class AgentSession {
2723
2746
  config.initialDiscoveredBuiltinToolSelectionIsExplicit === true
2724
2747
  ? this.#selectRestorableDiscoveredBuiltinToolNames(config.initialPersistedDiscoveredBuiltinToolNames ?? [])
2725
2748
  : undefined;
2726
- this.#pruneSelectedMCPToolNames();
2749
+ if (!config.preserveUnavailableInitialMCPToolSelection) this.#pruneSelectedMCPToolNames();
2727
2750
  const persistInitialMCPToolSelection =
2728
2751
  config.persistInitialMCPToolSelection === true && config.initialMCPToolSelectionIsExplicit !== false;
2729
2752
  const persistInitialDiscoveredBuiltinToolSelection =
@@ -3377,6 +3400,9 @@ export class AgentSession {
3377
3400
  getAgentId(): string | undefined {
3378
3401
  return this.#agentId;
3379
3402
  }
3403
+ get isDisposed(): boolean {
3404
+ return this.#isDisposed;
3405
+ }
3380
3406
 
3381
3407
  registerToolSessionCleanup(cleanup: () => Promise<void> | void): () => void {
3382
3408
  if (this.#isDisposed) throw new Error("Cannot register tool cleanup after session disposal has started.");
@@ -3384,6 +3410,23 @@ export class AgentSession {
3384
3410
  return () => this.#toolSessionCleanups.delete(cleanup);
3385
3411
  }
3386
3412
 
3413
+ registerToolSessionTransitionCleanup(cleanup: () => Promise<void> | void): () => void {
3414
+ if (this.#isDisposed)
3415
+ throw new Error("Cannot register tool transition cleanup after session disposal has started.");
3416
+ this.#toolSessionTransitionCleanups.add(cleanup);
3417
+ return () => this.#toolSessionTransitionCleanups.delete(cleanup);
3418
+ }
3419
+
3420
+ async #runToolSessionTransitionCleanups(): Promise<void> {
3421
+ const cleanups = Array.from(this.#toolSessionTransitionCleanups);
3422
+ this.#toolSessionTransitionCleanups.clear();
3423
+ const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
3424
+ for (const result of results) {
3425
+ if (result.status === "rejected")
3426
+ logger.warn("Tool session transition cleanup failed", { error: String(result.reason) });
3427
+ }
3428
+ }
3429
+
3387
3430
  async #runToolSessionCleanups(): Promise<void> {
3388
3431
  const cleanups = Array.from(this.#toolSessionCleanups);
3389
3432
  this.#toolSessionCleanups.clear();
@@ -3436,6 +3479,61 @@ export class AgentSession {
3436
3479
  manager.cancelAll({ ownerId: this.#agentId });
3437
3480
  }
3438
3481
 
3482
+ async #settleOwnAsyncJobsBeforeArtifactRetirement(): Promise<void> {
3483
+ const ownerId = this.#agentId;
3484
+ const manager = AsyncJobManager.instance();
3485
+ if (!ownerId || !manager) return;
3486
+ const lease = manager.beginOwnerSubagentShutdown(ownerId);
3487
+ if (!lease) throw new Error("Owned async cleanup is already in progress before artifact retirement.");
3488
+ let committed = false;
3489
+ try {
3490
+ manager.runOwnerProducerCleanupsStrict({ ownerId });
3491
+ const proof = await manager.cancelAndProveOwnerSubagents(lease);
3492
+ if (!proof.confirmed)
3493
+ throw new Error("Owned subagent cleanup could not be confirmed before artifact retirement.");
3494
+ if (!(await manager.waitForOwnerInFlightDeliveries(ownerId)))
3495
+ throw new Error("Owned async deliveries did not settle before artifact retirement.");
3496
+ if (!(await manager.cancelAndSettleOwnerJobs(ownerId)))
3497
+ throw new Error("Owned async jobs did not settle before artifact retirement.");
3498
+ committed = true;
3499
+ } finally {
3500
+ manager.finishOwnerSubagentShutdown(lease, committed ? "commit" : "release");
3501
+ }
3502
+ }
3503
+
3504
+ #scheduleDeferredOwnerShutdownFinalization(
3505
+ manager: AsyncJobManager,
3506
+ lease: OwnerSubagentShutdownLease,
3507
+ ownerId: string,
3508
+ ): void {
3509
+ const finalization = (async () => {
3510
+ while (!this.#isDisposed) {
3511
+ try {
3512
+ manager.runOwnerProducerCleanupsStrict({ ownerId });
3513
+ const proof = await manager.cancelAndProveOwnerSubagents(lease);
3514
+ if (!proof.confirmed) throw new Error(`owner_subagent_${proof.reason}`);
3515
+ if (!(await manager.waitForOwnerInFlightDeliveries(ownerId)))
3516
+ throw new Error("owner_delivery_settlement_timeout");
3517
+ if (!(await manager.cancelAndSettleOwnerJobs(ownerId))) throw new Error("owner_job_settlement_timeout");
3518
+ if (this.#isDisposed) break;
3519
+ this.sessionManager.retireEphemeralArtifactsAfterTransition();
3520
+ await this.#runToolSessionTransitionCleanups();
3521
+ manager.finishOwnerSubagentShutdown(lease, "commit");
3522
+ return;
3523
+ } catch (error) {
3524
+ logger.warn("Deferred owner shutdown finalization retry failed", {
3525
+ ownerId,
3526
+ error: error instanceof Error ? error.message : String(error),
3527
+ });
3528
+ await Bun.sleep(25);
3529
+ }
3530
+ }
3531
+ manager.finishOwnerSubagentShutdown(lease, "release");
3532
+ })();
3533
+ this.#deferredOwnerShutdownFinalizations.add(finalization);
3534
+ void finalization.finally(() => this.#deferredOwnerShutdownFinalizations.delete(finalization));
3535
+ }
3536
+
3439
3537
  #suppressOwnAsyncJobDeliveries(): void {
3440
3538
  if (!this.#agentId) return;
3441
3539
  const manager = AsyncJobManager.instance();
@@ -4647,6 +4745,11 @@ export class AgentSession {
4647
4745
  settleLease();
4648
4746
  return;
4649
4747
  }
4748
+ await this.#awaitStartupTurnBarrier();
4749
+ if (!canContinue()) {
4750
+ settleLease();
4751
+ return;
4752
+ }
4650
4753
  try {
4651
4754
  if (!options?.skipCompactionCheck) {
4652
4755
  await this.#checkEstimatedContextBeforePrompt();
@@ -5996,6 +6099,7 @@ export class AgentSession {
5996
6099
  // the session that owns the manager goes on to dispose it (which itself
5997
6100
  // nukes any leftover jobs and pending deliveries).
5998
6101
  this.#cancelOwnAsyncJobs();
6102
+ await Promise.allSettled(this.#deferredOwnerShutdownFinalizations);
5999
6103
  const ownedAsyncManager = this.#ownedAsyncJobManager;
6000
6104
  if (ownedAsyncManager) {
6001
6105
  const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
@@ -6007,6 +6111,7 @@ export class AgentSession {
6007
6111
  AsyncJobManager.setInstance(undefined);
6008
6112
  }
6009
6113
  }
6114
+ await this.#runToolSessionTransitionCleanups();
6010
6115
  await this.#runToolSessionCleanups();
6011
6116
  // Only disconnect the MCP manager THIS session owns (top-level sessions that
6012
6117
  // connected plugin-bundle MCP servers). Subagents and callers that merely
@@ -6834,6 +6939,12 @@ export class AgentSession {
6834
6939
  const built = await this.#runAdmittedBaseSystemPromptRebuild(() =>
6835
6940
  this.#rebuildSystemPrompt!(validToolNames, this.#toolRegistry),
6836
6941
  );
6942
+ if (this.#isDisposed) {
6943
+ if (generation === this.#baseSystemPromptGeneration) {
6944
+ this.#pendingAppliedToolSignature = undefined;
6945
+ }
6946
+ return;
6947
+ }
6837
6948
  if (generation === this.#baseSystemPromptGeneration) {
6838
6949
  this.#baseSystemPrompt = built.systemPrompt;
6839
6950
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
@@ -7319,6 +7430,8 @@ export class AgentSession {
7319
7430
  if (!canContinuePersistedHistory(this.agent.state.messages)) {
7320
7431
  throw new Error("Cannot continue from persisted message history");
7321
7432
  }
7433
+ await this.#awaitStartupTurnBarrier();
7434
+ if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
7322
7435
  this.#beginInFlight();
7323
7436
  let hindsightRecall: string | undefined;
7324
7437
  try {
@@ -10183,6 +10296,7 @@ export class AgentSession {
10183
10296
  // Last fallible gate while public getters still show the predecessor (#3138).
10184
10297
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
10185
10298
  this.sessionManager.commitPreparedNewSession(prepared);
10299
+ await this.#runToolSessionTransitionCleanups();
10186
10300
  } catch (error) {
10187
10301
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
10188
10302
  }
@@ -10252,6 +10366,7 @@ export class AgentSession {
10252
10366
  // Last fallible gate while public getters still show the predecessor (#3138).
10253
10367
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
10254
10368
  this.sessionManager.commitPreparedNewSession(prepared);
10369
+ await this.#runToolSessionTransitionCleanups();
10255
10370
  } catch (error) {
10256
10371
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
10257
10372
  }
@@ -10418,7 +10533,9 @@ export class AgentSession {
10418
10533
  }
10419
10534
  try {
10420
10535
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
10536
+ await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
10421
10537
  this.sessionManager.commitPreparedNewSession(prepared);
10538
+ await this.#runToolSessionTransitionCleanups();
10422
10539
  } catch (error) {
10423
10540
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
10424
10541
  }
@@ -11535,6 +11652,7 @@ export class AgentSession {
11535
11652
  const artifactManager = this.sessionManager.getArtifactManager();
11536
11653
  const prunedArtifacts: Array<{ entryId: string; id: string; toolType: string; originalText: string }> = [];
11537
11654
  let reservedArtifactId: string | undefined;
11655
+ let artifactAllocationAvailable = artifactManager !== null;
11538
11656
  if (artifactManager) {
11539
11657
  try {
11540
11658
  reservedArtifactId = (await artifactManager.allocatePath("tool-output")).id;
@@ -11542,14 +11660,24 @@ export class AgentSession {
11542
11660
  logger.warn("Failed to reserve artifact ID for pruned tool output", {
11543
11661
  error: error instanceof Error ? error.message : String(error),
11544
11662
  });
11663
+ artifactAllocationAvailable = false;
11545
11664
  }
11546
11665
  }
11547
11666
  const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG, {
11548
11667
  relaxedMinimum: overThreshold ? 0 : undefined,
11549
11668
  artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11550
11669
  artifactRef: candidate => {
11551
- if (!artifactManager) return undefined;
11552
- const id = reservedArtifactId ?? String(artifactManager.allocateId());
11670
+ if (!artifactManager || !artifactAllocationAvailable) return undefined;
11671
+ let id: string;
11672
+ try {
11673
+ id = reservedArtifactId ?? String(artifactManager.allocateId());
11674
+ } catch (error) {
11675
+ artifactAllocationAvailable = false;
11676
+ logger.warn("Failed to allocate artifact ID for pruned tool output", {
11677
+ error: error instanceof Error ? error.message : String(error),
11678
+ });
11679
+ return undefined;
11680
+ }
11553
11681
  reservedArtifactId = undefined;
11554
11682
  const toolType = (candidate.toolName ?? "tool-output").replace(/[^a-zA-Z0-9_-]/g, "_") || "tool-output";
11555
11683
  prunedArtifacts.push({ entryId: candidate.entryId, id, toolType, originalText: candidate.originalText });
@@ -12161,10 +12289,12 @@ export class AgentSession {
12161
12289
  const successorGateEmitter = this.#constructWorkflowGateEmitter(prepared.sessionId);
12162
12290
  // Last fallible action: verified local:// readiness from immutable staged options.
12163
12291
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
12292
+ await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
12164
12293
 
12165
12294
  // --- Commit boundary: synchronous adoption is the sole identity publication.
12166
12295
  this.sessionManager.commitPreparedNewSession(prepared);
12167
12296
  committed = true;
12297
+ await this.#runToolSessionTransitionCleanups();
12168
12298
  this.agent.reset();
12169
12299
  this.#syncAgentSessionId();
12170
12300
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -12195,7 +12325,6 @@ export class AgentSession {
12195
12325
  this.#resetIrcRosterDeliveryState();
12196
12326
  this.#planReferenceSent = false;
12197
12327
  this.#planReferencePath = "local://PLAN.md";
12198
- this.#cancelOwnAsyncJobs();
12199
12328
  // The predecessor's fenced async-job results (queued while the
12200
12329
  // transition held the delivery fence) belong to the handed-off session,
12201
12330
  // not the successor. Suppress and drop ONLY the async-result kind so they
@@ -14937,6 +15066,17 @@ export class AgentSession {
14937
15066
  // before the failure. This bypasses #hasCleanRetryReplaySafety because the
14938
15067
  // content-free check is the replay-safety guarantee for credential rotation.
14939
15068
  const canReplayRotatedCredential = credentialRotated;
15069
+ // Universal automatic replay-safety gate (#3791): once the failed attempt
15070
+ // carries observable assistant text, thinking, or tool-call content,
15071
+ // automatic session retry must not re-issue the request. This covers
15072
+ // configured legacy retry (which previously only gated first-event
15073
+ // timeouts) as well as bare defaults. Managed fallback keeps its
15074
+ // provisional discard path (events never published to the session).
15075
+ // Content-free credential rotation remains eligible above; first-event
15076
+ // timeout keeps its additional typed/scope checks earlier in this method.
15077
+ if (!managedFallback && assistantMessageHasVisibleOrToolContent(message)) {
15078
+ return false;
15079
+ }
14940
15080
  // Bare defaults retain their narrow watchdog and Codex admissions. A
14941
15081
  // first-event timeout adds the typed, content-free, current-clean-scope
14942
15082
  // requirement above; other transient watchdogs preserve legacy behavior.
@@ -16300,6 +16440,10 @@ export class AgentSession {
16300
16440
  onTransitionMutationStarted?: () => void;
16301
16441
  },
16302
16442
  ): Promise<boolean> {
16443
+ let ownerShutdownManager: AsyncJobManager | undefined;
16444
+ let ownerShutdownLease: OwnerSubagentShutdownLease | undefined;
16445
+ let ownerShutdownTransitionCommitted = false;
16446
+ let ownerShutdownFinalizationDeferred = false;
16303
16447
  this.#beginSessionTransition("switch-session");
16304
16448
  try {
16305
16449
  const previousSessionFile = this.sessionManager.getSessionFile();
@@ -16320,9 +16464,31 @@ export class AgentSession {
16320
16464
  }
16321
16465
  options?.onTransitionMutationStarted?.();
16322
16466
 
16323
- this.#disconnectFromAgent();
16467
+ const asyncManager = AsyncJobManager.instance();
16468
+ const ownerId = this.#agentId;
16469
+ const lease =
16470
+ switchingToDifferentSession && asyncManager && ownerId
16471
+ ? asyncManager.beginOwnerSubagentShutdown(ownerId)
16472
+ : undefined;
16473
+ if (switchingToDifferentSession && asyncManager && ownerId && !lease) {
16474
+ this.emitNotice(
16475
+ "error",
16476
+ "Cannot switch sessions while owned subagent cleanup is already in progress.",
16477
+ "switch-session-subagent-cleanup",
16478
+ );
16479
+ return false;
16480
+ }
16481
+ if (lease && asyncManager) {
16482
+ ownerShutdownManager = asyncManager;
16483
+ ownerShutdownLease = lease;
16484
+ }
16324
16485
  await this.abort();
16486
+ if (this.isCompacting) {
16487
+ this.abortCompaction();
16488
+ while (this.isCompacting) await Bun.sleep(10);
16489
+ }
16325
16490
 
16491
+ this.#disconnectFromAgent();
16326
16492
  // Flush pending writes before switching so restore snapshots reflect committed state.
16327
16493
  await this.sessionManager.flush();
16328
16494
  const previousSessionState = this.sessionManager.captureState();
@@ -16353,9 +16519,13 @@ export class AgentSession {
16353
16519
  ? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
16354
16520
  : undefined;
16355
16521
  let unavailableDefaultChainMessage: string | undefined;
16522
+ let transitionCleanupCommitted = false;
16356
16523
 
16357
16524
  try {
16358
- await this.sessionManager.setSessionFile(sessionPath);
16525
+ await this.sessionManager.setSessionFile(sessionPath, {
16526
+ deferEphemeralArtifactRetirement: switchingToDifferentSession,
16527
+ });
16528
+ if (switchingToDifferentSession) this.sessionManager.stageAdoptedArtifactManagerForTransition();
16359
16529
  // The successor identity is already rotated in the manager but not yet
16360
16530
  // published; gate its local:// root before publication so the agent,
16361
16531
  // workflow-gate emitter, and hooks cannot resolve against an ungated
@@ -16455,6 +16625,38 @@ export class AgentSession {
16455
16625
  this.#resetIrcRosterDeliveryState();
16456
16626
  }
16457
16627
 
16628
+ if (switchingToDifferentSession) {
16629
+ let ownerShutdownSettled = true;
16630
+ if (ownerShutdownManager && ownerShutdownLease && ownerId) {
16631
+ try {
16632
+ ownerShutdownManager.runOwnerProducerCleanupsStrict({ ownerId });
16633
+ const proof = await ownerShutdownManager.cancelAndProveOwnerSubagents(ownerShutdownLease);
16634
+ if (!proof.confirmed)
16635
+ throw new Error("Owned subagent cleanup could not be confirmed after successor validation.");
16636
+ if (!(await ownerShutdownManager.waitForOwnerInFlightDeliveries(ownerId)))
16637
+ throw new Error("Owned async deliveries did not settle after successor validation.");
16638
+ if (!(await ownerShutdownManager.cancelAndSettleOwnerJobs(ownerId)))
16639
+ throw new Error("Owned async jobs did not settle after successor validation.");
16640
+ } catch (error) {
16641
+ ownerShutdownSettled = false;
16642
+ ownerShutdownFinalizationDeferred = true;
16643
+ this.#scheduleDeferredOwnerShutdownFinalization(ownerShutdownManager, ownerShutdownLease, ownerId);
16644
+ this.#suppressOwnAsyncJobDeliveries();
16645
+ this.emitNotice(
16646
+ "error",
16647
+ `Successor session is active, but predecessor async cleanup did not settle: ${error instanceof Error ? error.message : String(error)}`,
16648
+ "switch-session-subagent-cleanup",
16649
+ );
16650
+ }
16651
+ }
16652
+ transitionCleanupCommitted = true;
16653
+ // Different files may intentionally carry the same copied session id; pathname transition is the commit signal.
16654
+ ownerShutdownTransitionCommitted = true;
16655
+ if (ownerShutdownSettled) {
16656
+ this.sessionManager.retireEphemeralArtifactsAfterTransition();
16657
+ await this.#runToolSessionTransitionCleanups();
16658
+ }
16659
+ }
16458
16660
  this.#reconnectToAgent();
16459
16661
  // Fence predecessor continuations before session_switch starts SDK runtime
16460
16662
  // teardown. The previous runtime waits for those continuations to settle;
@@ -16463,7 +16665,8 @@ export class AgentSession {
16463
16665
  this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
16464
16666
  // session_switch is the post-commit identity signal. SDK authority and
16465
16667
  // other identity-bound integrations must not observe the successor until
16466
- // messages, model state, MCP selections, and the agent subscription are live.
16668
+ // messages, model state, MCP selections, the agent subscription, and
16669
+ // session-scoped tool cleanup are complete.
16467
16670
  if (this.#extensionRunner) {
16468
16671
  await this.#extensionRunner.emit({
16469
16672
  type: "session_switch",
@@ -16474,6 +16677,7 @@ export class AgentSession {
16474
16677
  }
16475
16678
  return true;
16476
16679
  } catch (error) {
16680
+ if (transitionCleanupCommitted) throw error;
16477
16681
  this.sessionManager.restoreState(previousSessionState);
16478
16682
  this.#defaultFallbackController = undefined;
16479
16683
  this.#syncAgentSessionId(previousSessionState.sessionId);
@@ -16532,6 +16736,12 @@ export class AgentSession {
16532
16736
  throw error;
16533
16737
  }
16534
16738
  } finally {
16739
+ if (ownerShutdownManager && ownerShutdownLease && !ownerShutdownFinalizationDeferred) {
16740
+ ownerShutdownManager.finishOwnerSubagentShutdown(
16741
+ ownerShutdownLease,
16742
+ ownerShutdownTransitionCommitted ? "commit" : "release",
16743
+ );
16744
+ }
16535
16745
  this.#endSessionTransition();
16536
16746
  }
16537
16747
  }
@@ -16583,13 +16793,14 @@ export class AgentSession {
16583
16793
  : await this.sessionManager.prepareNewSession({ parentSession: previousSessionFile });
16584
16794
  try {
16585
16795
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
16796
+ await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
16586
16797
  this.sessionManager.commitPreparedNewSession(prepared);
16798
+ await this.#runToolSessionTransitionCleanups();
16587
16799
  } catch (error) {
16588
16800
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
16589
16801
  }
16590
16802
  this.#pendingNextTurnMessages = [];
16591
16803
  this.#scheduledHiddenNextTurnGeneration = undefined;
16592
- this.#cancelOwnAsyncJobs();
16593
16804
 
16594
16805
  this.#syncTodoPhasesFromBranch();
16595
16806
  this.#syncAgentSessionId();
@@ -13,6 +13,7 @@ import {
13
13
  ensureManagedDirectory,
14
14
  type ManagedSessionDescendantStore,
15
15
  publishManagedFileNoReplace,
16
+ publishManagedFileNoReplaceSync,
16
17
  } from "./internal/managed-session-storage";
17
18
  import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "./streaming-output";
18
19
 
@@ -78,7 +79,8 @@ export class ArtifactManager {
78
79
  readonly #dir: string;
79
80
  readonly #store: ManagedSessionDescendantStore | undefined;
80
81
  #dirCreated = false;
81
- #initialized = false;
82
+ #initialized: Promise<void> | undefined;
83
+ #initializedComplete = false;
82
84
 
83
85
  /**
84
86
  * @param dir Directory that will hold artifact files. Created lazily on first save.
@@ -118,10 +120,10 @@ export class ArtifactManager {
118
120
  else ensureManagedDirectory(this.#dir);
119
121
  this.#dirCreated = true;
120
122
  }
121
- if (!this.#initialized) {
122
- await this.#scanExistingIds();
123
- this.#initialized = true;
124
- }
123
+ // Memoized: concurrent first writers must not each rescan and restart the
124
+ // ID counter at the same value, which would collide on publish.
125
+ this.#initialized ??= this.#scanExistingIds();
126
+ await this.#initialized;
125
127
  }
126
128
 
127
129
  #filename(id: string, toolType: string): string {
@@ -129,6 +131,44 @@ export class ArtifactManager {
129
131
  return `${id}.${toolType}.log`;
130
132
  }
131
133
 
134
+ #claimFilename(id: number): string {
135
+ return `.artifact-id-${id}`;
136
+ }
137
+
138
+ #nextCandidateId(): number {
139
+ const id = this.#nextId++;
140
+ if (!Number.isSafeInteger(id) || id < 0) throw new Error("artifact_id_out_of_range");
141
+ return id;
142
+ }
143
+
144
+ async #claimNextId(): Promise<number> {
145
+ while (true) {
146
+ const id = this.#nextCandidateId();
147
+ try {
148
+ await this.#publish("", this.#claimFilename(id));
149
+ return id;
150
+ } catch (error) {
151
+ if (!(error instanceof Error) || error.message !== "destination_conflict") throw error;
152
+ }
153
+ }
154
+ }
155
+
156
+ #claimNextIdSync(): number {
157
+ if (!this.#initializedComplete)
158
+ throw new Error("ArtifactManager must be initialized before synchronous allocation");
159
+ while (true) {
160
+ const id = this.#nextCandidateId();
161
+ try {
162
+ const filename = this.#claimFilename(id);
163
+ if (this.#store) this.#store.publishNoReplaceSync(filename, new Uint8Array());
164
+ else publishManagedFileNoReplaceSync(path.join(this.#dir, filename), new Uint8Array());
165
+ return id;
166
+ } catch (error) {
167
+ if (!(error instanceof Error) || error.message !== "destination_conflict") throw error;
168
+ }
169
+ }
170
+ }
171
+
132
172
  async #publish(content: string, filename: string): Promise<void> {
133
173
  if (this.#store) await this.#store.publishNoReplace(filename, Buffer.from(content, "utf8"));
134
174
  else await publishManagedFileNoReplace(path.join(this.#dir, filename), Buffer.from(content, "utf8"));
@@ -250,22 +290,25 @@ export class ArtifactManager {
250
290
  const files = await this.listFiles();
251
291
  let maxId = -1;
252
292
  for (const file of files) {
253
- // Files are named: {id}.{toolType}.log
254
- const match = file.match(/^(\d+)\..*\.log$/);
293
+ // Published artifacts are `{id}.{toolType}.log`; hidden claim files reserve
294
+ // the numeric namespace across independent managers and processes.
295
+ const match = file.match(/^(\d+)\..*\.log$/) ?? file.match(/^\.artifact-id-(\d+)$/);
255
296
  if (match) {
256
- const id = parseInt(match[1], 10);
297
+ const id = Number(match[1]);
298
+ if (!Number.isSafeInteger(id) || id < 0) throw new Error("artifact_id_out_of_range");
257
299
  if (id > maxId) maxId = id;
258
300
  }
259
301
  }
260
302
  this.#nextId = maxId + 1;
303
+ this.#initializedComplete = true;
261
304
  }
262
305
 
263
306
  /**
264
- * Atomically allocate next artifact ID.
265
- * IDs are sequential within the session.
307
+ * Atomically claim the next artifact ID after this manager has been initialized.
308
+ * Prefer `allocatePath` or `save`; this synchronous seam exists for pruning callbacks.
266
309
  */
267
310
  allocateId(): number {
268
- return this.#nextId++;
311
+ return this.#claimNextIdSync();
269
312
  }
270
313
 
271
314
  /**
@@ -276,7 +319,7 @@ export class ArtifactManager {
276
319
  */
277
320
  async allocatePath(toolType: string): Promise<{ id: string; path?: string }> {
278
321
  await this.#ensureDir();
279
- const id = String(this.allocateId());
322
+ const id = String(await this.#claimNextId());
280
323
  if (this.#store) return { id };
281
324
  return { id, path: path.join(this.#dir, this.#filename(id, toolType)) };
282
325
  }
@@ -288,7 +331,7 @@ export class ArtifactManager {
288
331
  */
289
332
  async save(content: string, toolType: string, options: ArtifactSaveOptions = {}): Promise<string> {
290
333
  await this.#ensureDir();
291
- const id = String(this.allocateId());
334
+ const id = String(await this.#claimNextId());
292
335
  const maxBytes = Math.max(0, options.maxBytes ?? DEFAULT_ARTIFACT_MAX_BYTES);
293
336
  const contentBytes = Buffer.byteLength(content, "utf-8");
294
337
  const published =