@gajae-code/coding-agent 0.12.19 → 0.12.21

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 (100) hide show
  1. package/CHANGELOG.md +27 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +302 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tools/todo-contract.d.ts +10 -1
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/cli/session-cli.ts +8 -1
  77. package/src/sdk/host/query/handlers.ts +1 -1
  78. package/src/sdk/host/query/revision-store.ts +48 -12
  79. package/src/sdk/host/session-runtime.test.ts +50 -0
  80. package/src/sdk/host/session-runtime.ts +12 -2
  81. package/src/sdk/lifecycle-session.ts +26 -3
  82. package/src/sdk/mcp/server.ts +6 -1
  83. package/src/sdk/prompt-failure.ts +42 -0
  84. package/src/sdk/prompt-watchdog.ts +162 -0
  85. package/src/sdk/session-reconnect.ts +43 -0
  86. package/src/sdk/startup-capability.ts +13 -4
  87. package/src/session/agent-session.ts +541 -226
  88. package/src/session/blob-store.ts +89 -0
  89. package/src/session/internal/managed-session-scope.ts +36 -26
  90. package/src/session/internal/managed-session-storage.ts +303 -32
  91. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  92. package/src/session/session-manager.ts +7057 -399
  93. package/src/session/session-storage.ts +1134 -11
  94. package/src/task/executor.ts +17 -4
  95. package/src/tools/bash.ts +109 -7
  96. package/src/tools/descriptors.test.ts +51 -0
  97. package/src/tools/find.ts +55 -26
  98. package/src/tools/todo-contract.ts +82 -13
  99. package/src/tools/todo-write.ts +2 -5
  100. package/src/tui/output-block.ts +11 -1
@@ -431,8 +431,8 @@ import type {
431
431
  RecoveryHydrationPromotionFence,
432
432
  SessionContext,
433
433
  SessionEntry,
434
- SessionManager,
435
434
  SessionManagerCloseOutcome,
435
+ SessionMemoryStats,
436
436
  } from "./session-manager";
437
437
 
438
438
  import {
@@ -441,6 +441,8 @@ import {
441
441
  getSessionMessageEntryId,
442
442
  getSessionMessageObservationId,
443
443
  SessionAppendPersistenceError,
444
+ SessionContextTooLargeError,
445
+ SessionManager,
444
446
  transferSessionMessageIdentity,
445
447
  } from "./session-manager";
446
448
  import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal";
@@ -744,6 +746,17 @@ type AutoCompactionTerminalStatus =
744
746
  | { kind: "skipped"; continuationScheduled?: boolean }
745
747
  | { kind: "failed" };
746
748
 
749
+ /**
750
+ * R3.2 pre-submit seam: `build` assembles the attempt messages (Phase A once,
751
+ * Phase B per attempt) and may throw the typed overflow error; `reset` drops the
752
+ * cached Phase B attempt messages so the next `build` rebuilds them (used after a
753
+ * forced compaction retry).
754
+ */
755
+ type PreSubmitBuilder = {
756
+ build: () => Promise<AgentMessage[] | null>;
757
+ reset: () => void;
758
+ };
759
+
747
760
  /** Options for AgentSession.prompt() */
748
761
  export interface PromptOptions {
749
762
  /** Whether to expand file-based prompt templates (default: true) */
@@ -769,8 +782,8 @@ export interface PromptOptions {
769
782
  */
770
783
  onPreflightAccepted?: () => void;
771
784
  /**
772
- * Awaitable durable-accept fence. Called after preflight and before queue mutation
773
- * or `#promptAgentWithIdleRetry`. SDK bus installs a closure that fsyncs acceptance.
785
+ * Awaitable durable-accept fence. Called after preflight and immediately before
786
+ * agent execution begins. SDK bus installs a closure that fsyncs acceptance.
774
787
  */
775
788
  onPreflightAcceptCommit?: () => void | Promise<void>;
776
789
  /** Skill-only: prepared metadata before the durable fence (path/lineCount/cleanedArgs). */
@@ -904,6 +917,7 @@ export interface SessionStats {
904
917
  premiumRequests: number;
905
918
  cost: number;
906
919
  costBreakdown?: Usage["cost"];
920
+ sessionMemory: SessionMemoryStats;
907
921
  }
908
922
 
909
923
  /** Internal marker for hook messages queued through the agent loop */
@@ -1798,7 +1812,7 @@ function deobfuscateSessionContext(context: SessionContext, obfuscator: SecretOb
1798
1812
 
1799
1813
  export class AgentSession {
1800
1814
  readonly agent: Agent;
1801
- readonly sessionManager: SessionManager;
1815
+ sessionManager: SessionManager;
1802
1816
  readonly settings: Settings;
1803
1817
  readonly memoryBackend: LazyService<MemoryBackend>;
1804
1818
  readonly notificationSessionController: NotificationSessionController | undefined;
@@ -2015,7 +2029,11 @@ export class AgentSession {
2015
2029
 
2016
2030
  // Bash execution state
2017
2031
  #bashAbortControllers = new Set<AbortController>();
2018
- #pendingBashMessages: BashExecutionMessage[] = [];
2032
+ #pendingBashMessages: Array<{
2033
+ message: BashExecutionMessage;
2034
+ onPersisted?: () => void;
2035
+ appendedToAgent: boolean;
2036
+ }> = [];
2019
2037
  #foregroundBashBackgroundRequestHandler: (() => void) | undefined;
2020
2038
 
2021
2039
  // Python execution state
@@ -2024,6 +2042,7 @@ export class AgentSession {
2024
2042
  /** Idempotent unregister handle for this session's resource-GC registration. */
2025
2043
  #unregisterResourceGc?: () => void;
2026
2044
  #unregisterRuntimeStateFinalizer?: () => void;
2045
+ #unregisterSessionMemorySettings?: () => void;
2027
2046
  /**
2028
2047
  * AsyncJobManager owned by this session (top-level only). Subagents leave
2029
2048
  * this undefined and **MUST NOT** dispose the global instance on teardown.
@@ -2031,7 +2050,11 @@ export class AgentSession {
2031
2050
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
2032
2051
  readonly #ownedMcpManager: MCPManager | undefined;
2033
2052
  #startupTurnBarrier: Promise<void> | undefined;
2034
- #pendingPythonMessages: PythonExecutionMessage[] = [];
2053
+ #pendingPythonMessages: Array<{
2054
+ message: PythonExecutionMessage;
2055
+ onPersisted?: () => void;
2056
+ appendedToAgent: boolean;
2057
+ }> = [];
2035
2058
  #activeEvalExecutions = new Set<Promise<unknown>>();
2036
2059
  #evalExecutionDisposing = false;
2037
2060
 
@@ -2584,14 +2607,62 @@ export class AgentSession {
2584
2607
  }
2585
2608
  }
2586
2609
 
2587
- #endInFlight(): void {
2610
+ #endInFlight(): unknown {
2588
2611
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
2589
- if (this.#promptInFlightCount === 0) {
2590
- this.#releasePowerAssertion();
2591
- this.#refreshTeamWorkerHeartbeat();
2612
+ if (this.#promptInFlightCount !== 0) return undefined;
2613
+
2614
+ this.#releasePowerAssertion();
2615
+ this.#refreshTeamWorkerHeartbeat();
2616
+ let flushError: unknown;
2617
+ try {
2618
+ // The turn is over, so nothing can split a tool_use/tool_result pair any
2619
+ // more: a `!`/`$` block that finished mid-stream must own its place in
2620
+ // agent state and the session now, not at the next prompt. Until it does,
2621
+ // the TUI shows output the transcript lacks and `onPersisted` stays unfired,
2622
+ // so a rebuild in that gap drops the only rendering of the execution.
2623
+ this.#flushPendingPromptMessages();
2624
+ } catch (error) {
2625
+ flushError = error;
2626
+ }
2627
+ this.#flushPendingAgentEnd();
2628
+ return flushError;
2629
+ }
2630
+ async #settleEndedInFlight(promptWait?: "publication" | "full"): Promise<void> {
2631
+ const flushError = this.#endInFlight();
2632
+ const predecessorPromptStillInFlight = this.#promptInFlightCount > 0;
2633
+ if (promptWait === "publication") {
2634
+ await this.#agentEndPublicationPromise;
2635
+ } else if (promptWait === "full") {
2636
+ await this.#agentEndHandlingPromise;
2637
+ await this.#waitForPostPromptRecovery();
2638
+ await this.#agentEndPublicationPromise;
2639
+ }
2640
+ // A post-prompt continuation runs before its predecessor prompt returns. Its
2641
+ // publication must settle, but session-wide settlement can still be owned by
2642
+ // that predecessor; waiting here would make each prompt wait on the other.
2643
+ if (!predecessorPromptStillInFlight) await this.#waitForSessionSettlement();
2644
+ if (flushError) throw flushError;
2645
+ }
2646
+
2647
+ #flushPendingPromptMessages(): void {
2648
+ const errors: unknown[] = [];
2649
+ try {
2650
+ this.#flushPendingBashMessages();
2651
+ } catch (error) {
2652
+ errors.push(error);
2653
+ }
2654
+ try {
2655
+ this.#flushPendingPythonMessages();
2656
+ } catch (error) {
2657
+ errors.push(error);
2658
+ }
2659
+ try {
2592
2660
  this.#flushPendingBackgroundExchanges();
2593
- this.#flushPendingAgentEnd();
2661
+ } catch (error) {
2662
+ errors.push(error);
2594
2663
  }
2664
+ if (errors.length === 1) throw errors[0];
2665
+ if (errors.length > 1) throw new AggregateError(errors, "Multiple deferred prompt messages failed to flush");
2595
2666
  }
2596
2667
 
2597
2668
  #refreshTeamWorkerHeartbeat(): void {
@@ -2670,6 +2741,12 @@ export class AgentSession {
2670
2741
  this.agent.bindRunCancellationDomainBridge(this.#runCancellationDomains, this.#agentSessionClaimKey);
2671
2742
  this.sessionManager = config.sessionManager;
2672
2743
  this.settings = config.settings;
2744
+ this.sessionManager.setSessionMemoryMode(this.settings.get("sessionMemory.mode"));
2745
+ this.#unregisterSessionMemorySettings = this.settings.onChanged(settingPath => {
2746
+ if (settingPath === "sessionMemory.mode") {
2747
+ this.sessionManager.setSessionMemoryMode(this.settings.get("sessionMemory.mode"));
2748
+ }
2749
+ });
2673
2750
  this.memoryBackend = config.memoryBackend ?? createMemoryBackendService(this.settings);
2674
2751
  this.#workerIntegrationScheduler = new WorkerIntegrationRequestScheduler(
2675
2752
  config.workerIntegrationRequest ??
@@ -6256,6 +6333,8 @@ export class AgentSession {
6256
6333
  // No-op when this session opened no tabs. Failure is logged, not thrown.
6257
6334
  this.#unregisterResourceGc?.();
6258
6335
  this.#unregisterResourceGc = undefined;
6336
+ this.#unregisterSessionMemorySettings?.();
6337
+ this.#unregisterSessionMemorySettings = undefined;
6259
6338
  if (ownerTerminalContextFromEnvironment() === null) this.#unregisterRuntimeStateFinalizer?.();
6260
6339
  this.#unregisterRuntimeStateFinalizer = undefined;
6261
6340
  await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
@@ -6320,6 +6399,8 @@ export class AgentSession {
6320
6399
  const kernelOwnerId = this.#evalKernelOwnerId;
6321
6400
  this.#unregisterResourceGc?.();
6322
6401
  this.#unregisterResourceGc = undefined;
6402
+ this.#unregisterSessionMemorySettings?.();
6403
+ this.#unregisterSessionMemorySettings = undefined;
6323
6404
  this.#unregisterTeamWorkerAsyncJobChange?.();
6324
6405
  this.#unregisterTeamWorkerAsyncJobChange = undefined;
6325
6406
  this.#teamWorkerHeartbeat?.dispose();
@@ -7604,8 +7685,7 @@ export class AgentSession {
7604
7685
  await this.#waitForPostPromptRecovery();
7605
7686
  } finally {
7606
7687
  this.#removeEphemeralCustomMessages();
7607
- this.#endInFlight();
7608
- await this.#waitForSessionSettlement();
7688
+ await this.#settleEndedInFlight();
7609
7689
  }
7610
7690
  }
7611
7691
 
@@ -8799,10 +8879,8 @@ export class AgentSession {
8799
8879
  this.#overflowMaintenanceAttempts = 0;
8800
8880
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8801
8881
  }
8802
- // Flush any pending bash messages before the new prompt
8803
- this.#flushPendingBashMessages();
8804
- this.#flushPendingPythonMessages();
8805
- this.#flushPendingBackgroundExchanges();
8882
+ // Retry anything whose previous post-turn persistence failed before the new prompt.
8883
+ this.#flushPendingPromptMessages();
8806
8884
 
8807
8885
  // Reset todo reminder count on new user prompt
8808
8886
  this.#todoReminderCount = 0;
@@ -8837,160 +8915,206 @@ export class AgentSession {
8837
8915
  ]);
8838
8916
  }
8839
8917
 
8840
- // Build messages array (session context, eager todo prelude, then active prompt message)
8841
- const messages: AgentMessage[] = [];
8842
- const planReferenceMessage = await this.#buildPlanReferenceMessage?.();
8843
- if (planReferenceMessage) {
8844
- messages.push(planReferenceMessage);
8845
- }
8846
- const planModeMessage = await this.#buildAutomaticPlanModeMessage();
8847
- if (planModeMessage) {
8848
- messages.push(planModeMessage);
8849
- }
8850
- const goalModeMessage = this.#buildAutomaticGoalModeMessage();
8851
- if (goalModeMessage) {
8852
- messages.push(goalModeMessage);
8853
- }
8854
- const volatileProjectContextMessage = await this.#buildVolatileProjectContextMessage();
8855
- messages.push(volatileProjectContextMessage);
8856
- const untrustedMcpServerInstructionsMessage = this.#buildUntrustedMcpServerInstructionsMessage();
8857
- if (untrustedMcpServerInstructionsMessage) messages.push(untrustedMcpServerInstructionsMessage);
8858
-
8859
- if (rosterClaim && this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch)) {
8860
- messages.push(rosterClaim.message);
8861
- } else if (rosterClaim) {
8862
- this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
8863
- }
8864
- if (options?.prependMessages) {
8865
- messages.push(...options.prependMessages);
8866
- }
8867
-
8868
- messages.push(message);
8869
-
8870
- // Early bail-out: a generation change or cancellation during setup must
8871
- // terminate preflight rather than retaining SDK prompt authority.
8872
- if (this.#isPromptPreflightCancelled(generation, preflightSignal)) {
8873
- this.#resetInjectedContextSignatures();
8874
- // A newer abort/prompt cycle superseded this preflight. Callers awaiting
8875
- // acceptance (onPreflightAccepted) must be told it never ran; direct
8876
- // callers (e.g. prompt() aborted during a TTSR wait) resolve gracefully.
8877
- if (options?.onPreflightAccepted || options?.onPreflightAcceptCommit) throw promptPreflightCancelledError();
8878
- return;
8879
- }
8880
-
8881
- // Inject any pending "nextTurn" messages as context alongside the user message
8918
+ // R3.2: one-time Phase A products are captured inside the closure and
8919
+ // executed once after admission acceptance; Phase B reassembles the
8920
+ // attempt messages after every idle wait / compact retry. Pending
8921
+ // next-turn messages are captured WITHOUT draining — the drain moves to
8922
+ // the run-accepted wrapper (R3.3) so AgentBusy/idle/compact-before-
8923
+ // acceptance never consume them.
8882
8924
  pendingNextTurnMessageCount = this.#pendingNextTurnMessages.length;
8883
8925
  hasPendingNextTurnMessages = pendingNextTurnMessageCount > 0;
8884
- for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
8885
- messages.push(msg);
8886
- }
8887
-
8888
- // Auto-read @filepath mentions
8889
- const fileMentions = extractFileMentions(expandedText);
8890
- if (fileMentions.length > 0) {
8891
- const cwd = this.sessionManager.getCwd();
8892
- // Collect resolved paths already shown (read or mentioned) in the recent
8893
- // window so a repeat @mention emits a compact note instead of the full body.
8894
- const RECENT_MENTION_WINDOW = 40;
8895
- const recentlyShownPaths = new Set<string>();
8896
- for (const entry of this.sessionManager.getBranch().slice(-RECENT_MENTION_WINDOW)) {
8897
- if (entry.type !== "message") continue;
8898
- const msg = entry.message;
8899
- if (msg.role === "fileMention") {
8900
- for (const file of msg.files) {
8901
- if (!file.duplicate && !file.pruned) recentlyShownPaths.add(resolveReadPath(file.path, cwd));
8902
- }
8903
- } else if (msg.role === "toolResult") {
8904
- const resolved = (msg.details as { resolvedPath?: unknown } | undefined)?.resolvedPath;
8905
- if (typeof resolved === "string" && resolved) recentlyShownPaths.add(resolveReadPath(resolved, cwd));
8906
- }
8907
- }
8908
- const fileMentionMessages = await generateFileMentionMessages(fileMentions, cwd, {
8909
- autoResizeImages: this.settings.get("images.autoResize"),
8910
- useHashLines: resolveFileDisplayMode(this).hashLines,
8911
- maxInlineBytes: this.settings.get("tools.fileMentionInlineBytes") * 1024,
8912
- recentlyShownPaths,
8913
- });
8914
- messages.push(...fileMentionMessages);
8915
- }
8916
-
8917
- const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
8918
- hindsightRecall = this.getHindsightSessionState()?.getRecallSnippetForInjection();
8919
- if (hindsightRecall) {
8920
- // Recall is provider-only context for this request. It must precede the
8921
- // actual prompt but never become part of durable session history.
8922
- const promptIndex = messages.lastIndexOf(message);
8923
- messages.splice(promptIndex, 0, {
8924
- role: "custom",
8925
- customType: "hindsight-recall",
8926
- content: hindsightRecall,
8927
- display: false,
8928
- attribution: "agent",
8929
- timestamp: Date.now(),
8930
- });
8931
- }
8932
-
8933
8926
  const promptAttribution: "user" | "agent" | undefined =
8934
8927
  "attribution" in message ? message.attribution : undefined;
8935
-
8936
- // Emit before_agent_start extension event. Race hook completion with prompt
8937
- // cancellation so a wedged hook cannot retain SDK prompt authority.
8938
- if (this.#extensionRunner?.hasHandlers("before_agent_start")) this.#markRetryReplayUnsafe();
8939
-
8940
- if (this.#extensionRunner) {
8941
- const result = await this.#awaitPromptPreflight(
8942
- generation,
8943
- preflightSignal,
8944
- this.#extensionRunner.emitBeforeAgentStart(expandedText, options?.images, beforeAgentStartSystemPrompt),
8945
- );
8946
- if (result?.messages) {
8947
- this.#appendBeforeAgentStartCustomMessages(messages, result.messages, promptAttribution, message.role);
8948
- }
8949
-
8950
- if (result?.systemPrompt !== undefined) {
8951
- this.agent.setSystemPrompt(result.systemPrompt);
8952
- } else {
8953
- this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
8928
+ let phaseACompleted = false;
8929
+ let fileMentionMessages: AgentMessage[] = [];
8930
+ let beforeAgentStartResultMessages: BeforeAgentStartInternalMessage[] = [];
8931
+ const contributedMessages: BeforeAgentStartInternalMessage[] = [];
8932
+ let recallMarked = false;
8933
+ let planReferenceMessage: CustomMessage | null = null;
8934
+ // Phase B attempt cache. AgentBusy/idle and forced-compaction retries clear
8935
+ // this cache so live plan/goal/volatile/MCP overlays are rebuilt. One-shot
8936
+ // Phase A products (including the plan reference) remain stable.
8937
+ let attemptMessages: AgentMessage[] | undefined;
8938
+
8939
+ const buildPreSubmit = async (): Promise<AgentMessage[] | null> => {
8940
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8941
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
8942
+ this.#resetInjectedContextSignatures();
8943
+ return null;
8954
8944
  }
8955
- } else {
8956
- this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
8957
- }
8958
-
8959
- // Invoke first-party internal before-agent-start contributors. These run
8960
- // alongside the extension runner (not via user-loaded hooks) and append
8961
- // through the same custom-message attribution path. Errors are nonfatal.
8962
- if (this.#beforeAgentStartContributors.length > 0) {
8963
- const contributed: BeforeAgentStartInternalMessage[] = [];
8964
- for (const contributor of this.#beforeAgentStartContributors) {
8965
- try {
8966
- const msg = await this.#awaitPromptPreflight(
8945
+ if (!phaseACompleted) {
8946
+ phaseACompleted = true;
8947
+ // Phase A (one-time side-effectful products; runs once).
8948
+ const fileMentions = extractFileMentions(expandedText);
8949
+ if (fileMentions.length > 0) {
8950
+ const cwd = this.sessionManager.getCwd();
8951
+ // Collect resolved paths already shown (read or mentioned) in the
8952
+ // recent window so a repeat @mention emits a compact note instead
8953
+ // of the full body.
8954
+ const RECENT_MENTION_WINDOW = 40;
8955
+ const recentlyShownPaths = new Set<string>();
8956
+ for (const entry of this.sessionManager.getBranch().slice(-RECENT_MENTION_WINDOW)) {
8957
+ if (entry.type !== "message") continue;
8958
+ const msg = entry.message;
8959
+ if (msg.role === "fileMention") {
8960
+ for (const file of msg.files) {
8961
+ if (!file.duplicate && !file.pruned) recentlyShownPaths.add(resolveReadPath(file.path, cwd));
8962
+ }
8963
+ } else if (msg.role === "toolResult") {
8964
+ const resolved = (msg.details as { resolvedPath?: unknown } | undefined)?.resolvedPath;
8965
+ if (typeof resolved === "string" && resolved)
8966
+ recentlyShownPaths.add(resolveReadPath(resolved, cwd));
8967
+ }
8968
+ }
8969
+ fileMentionMessages = await generateFileMentionMessages(fileMentions, cwd, {
8970
+ autoResizeImages: this.settings.get("images.autoResize"),
8971
+ useHashLines: resolveFileDisplayMode(this).hashLines,
8972
+ maxInlineBytes: this.settings.get("tools.fileMentionInlineBytes") * 1024,
8973
+ recentlyShownPaths,
8974
+ });
8975
+ }
8976
+ const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
8977
+ hindsightRecall = this.getHindsightSessionState()?.getRecallSnippetForInjection();
8978
+ planReferenceMessage = await this.#buildPlanReferenceMessage();
8979
+ // Emit before_agent_start extension event. Race hook completion with
8980
+ // prompt cancellation so a wedged hook cannot retain SDK prompt authority.
8981
+ if (this.#extensionRunner?.hasHandlers("before_agent_start")) this.#markRetryReplayUnsafe();
8982
+ if (this.#extensionRunner) {
8983
+ const result = await this.#awaitPromptPreflight(
8967
8984
  generation,
8968
8985
  preflightSignal,
8969
- contributor({
8970
- prompt: expandedText,
8971
- images: options?.images,
8972
- sessionId: this.sessionId,
8973
- }),
8986
+ this.#extensionRunner.emitBeforeAgentStart(
8987
+ expandedText,
8988
+ options?.images,
8989
+ beforeAgentStartSystemPrompt,
8990
+ ),
8974
8991
  );
8975
- if (msg) contributed.push(msg);
8976
- } catch (err) {
8977
- if (this.#isPromptPreflightCancelled(generation, preflightSignal))
8978
- throw promptPreflightCancelledError();
8979
- logger.debug("before_agent_start contributor failed", { error: String(err) });
8992
+ if (result?.messages) beforeAgentStartResultMessages = [...result.messages];
8993
+ if (result?.systemPrompt !== undefined) {
8994
+ this.agent.setSystemPrompt(result.systemPrompt);
8995
+ } else {
8996
+ this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
8997
+ }
8998
+ } else {
8999
+ this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
9000
+ }
9001
+ // Invoke first-party internal before-agent-start contributors. These
9002
+ // run alongside the extension runner (not via user-loaded hooks) and
9003
+ // append through the same custom-message attribution path. Errors are nonfatal.
9004
+ if (this.#beforeAgentStartContributors.length > 0) {
9005
+ for (const contributor of this.#beforeAgentStartContributors) {
9006
+ try {
9007
+ const msg = await this.#awaitPromptPreflight(
9008
+ generation,
9009
+ preflightSignal,
9010
+ contributor({
9011
+ prompt: expandedText,
9012
+ images: options?.images,
9013
+ sessionId: this.sessionId,
9014
+ }),
9015
+ );
9016
+ if (msg) contributedMessages.push(msg);
9017
+ } catch (err) {
9018
+ if (this.#isPromptPreflightCancelled(generation, preflightSignal))
9019
+ throw promptPreflightCancelledError();
9020
+ logger.debug("before_agent_start contributor failed", { error: String(err) });
9021
+ }
9022
+ }
8980
9023
  }
9024
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8981
9025
  }
8982
- this.#appendBeforeAgentStartCustomMessages(messages, contributed, promptAttribution, message.role);
8983
- }
8984
9026
 
8985
- // Abort can race asynchronous preflight work. The injection signatures were
8986
- // consumed while building context, but no prompt was accepted, so reset them.
8987
- if (this.#isPromptPreflightCancelled(generation, preflightSignal)) {
8988
- this.#resetInjectedContextSignatures();
8989
- // Ack-waiting callers are told the preflight never ran; direct callers
8990
- // (aborted after setup) resolve gracefully as before f24f46ff5.
8991
- if (options?.onPreflightAccepted || options?.onPreflightAcceptCommit) throw promptPreflightCancelledError();
8992
- return;
8993
- }
9027
+ // Phase B (attempt-dependent; re-runs after every idle wait / compact retry).
9028
+ // R3.2 overflow preflight: materialize the session context synchronously so
9029
+ // an over-budget graph throws SessionContextTooLargeError before any prompt.
9030
+ this.sessionManager.buildSessionContext();
9031
+ if (attemptMessages) {
9032
+ // Idle/AgentBusy retry: reuse the previously assembled attempt
9033
+ // messages; a superseded roster claim releases and the prompt
9034
+ // proceeds without it, matching the pre-seam reuse behavior.
9035
+ if (rosterClaim && !this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch)) {
9036
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
9037
+ }
9038
+ return attemptMessages;
9039
+ }
9040
+ const messages: AgentMessage[] = [];
9041
+ const currentPlanReferenceMessage = planReferenceMessage;
9042
+ if (currentPlanReferenceMessage) {
9043
+ messages.push(currentPlanReferenceMessage);
9044
+ }
9045
+ const planModeMessage = await this.#buildAutomaticPlanModeMessage();
9046
+ if (planModeMessage) {
9047
+ messages.push(planModeMessage);
9048
+ }
9049
+ const goalModeMessage = this.#buildAutomaticGoalModeMessage();
9050
+ if (goalModeMessage) {
9051
+ messages.push(goalModeMessage);
9052
+ }
9053
+ const volatileProjectContextMessage = await this.#buildVolatileProjectContextMessage();
9054
+ messages.push(volatileProjectContextMessage);
9055
+ const untrustedMcpServerInstructionsMessage = this.#buildUntrustedMcpServerInstructionsMessage();
9056
+ if (untrustedMcpServerInstructionsMessage) messages.push(untrustedMcpServerInstructionsMessage);
9057
+
9058
+ // Roster: one Phase A claim, revalidated and reused on every attempt;
9059
+ // a superseded claim releases and the prompt proceeds without it.
9060
+ if (rosterClaim && this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch)) {
9061
+ messages.push(rosterClaim.message);
9062
+ } else if (rosterClaim) {
9063
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
9064
+ }
9065
+ if (options?.prependMessages) {
9066
+ messages.push(...options.prependMessages);
9067
+ }
9068
+
9069
+ const promptIndex = messages.length;
9070
+ messages.push(message);
9071
+
9072
+ // Re-present captured pending next-turn messages (never drained here).
9073
+ for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
9074
+ messages.push(msg);
9075
+ }
9076
+ messages.push(...fileMentionMessages);
9077
+ if (hindsightRecall) {
9078
+ // Recall is provider-only context for this request. It must precede
9079
+ // the actual prompt but never become part of durable session history.
9080
+ messages.splice(promptIndex, 0, {
9081
+ role: "custom",
9082
+ customType: "hindsight-recall",
9083
+ content: hindsightRecall,
9084
+ display: false,
9085
+ attribution: "agent",
9086
+ timestamp: Date.now(),
9087
+ });
9088
+ }
9089
+ if (beforeAgentStartResultMessages.length > 0) {
9090
+ this.#appendBeforeAgentStartCustomMessages(
9091
+ messages,
9092
+ beforeAgentStartResultMessages,
9093
+ promptAttribution,
9094
+ message.role,
9095
+ );
9096
+ }
9097
+ if (contributedMessages.length > 0) {
9098
+ this.#appendBeforeAgentStartCustomMessages(
9099
+ messages,
9100
+ contributedMessages,
9101
+ promptAttribution,
9102
+ message.role,
9103
+ );
9104
+ }
9105
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
9106
+ this.#resetInjectedContextSignatures();
9107
+ return null;
9108
+ }
9109
+ attemptMessages = messages;
9110
+ return messages;
9111
+ };
9112
+ const preSubmit: PreSubmitBuilder = {
9113
+ build: buildPreSubmit,
9114
+ reset: () => {
9115
+ attemptMessages = undefined;
9116
+ },
9117
+ };
8994
9118
 
8995
9119
  const agentPromptOptions = {
8996
9120
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
@@ -8999,21 +9123,27 @@ export class AgentSession {
8999
9123
  this.#acceptRunHandle(handle);
9000
9124
  options?.onRunAccepted?.(handle);
9001
9125
  options?.admissionLease?.release();
9002
- if (hindsightRecall) this.getHindsightSessionState()?.markRecallSnippetInjected(hindsightRecall);
9126
+ // R3.3: the accepted-run wrapper is the exact acceptance boundary —
9127
+ // pending next-turn drain and the exactly-once recall mark live here.
9128
+ if (hindsightRecall && !recallMarked) {
9129
+ recallMarked = true;
9130
+ this.getHindsightSessionState()?.markRecallSnippetInjected(hindsightRecall);
9131
+ }
9132
+ if (pendingNextTurnMessageCount > 0) {
9133
+ this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
9134
+ }
9135
+ if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
9003
9136
  },
9004
9137
  };
9005
- if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9006
- else options?.onPreflightAccepted?.();
9007
- if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
9008
- this.#resetInjectedContextSignatures();
9009
- return;
9010
- }
9011
- this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
9012
- if (pendingNextTurnMessageCount > 0) {
9013
- this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
9014
- }
9015
- if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
9016
- await this.#promptAgentWithIdleRetry(messages, agentPromptOptions, predecessorAgentEndHold);
9138
+ await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
9139
+ signal: preflightSignal,
9140
+ resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
9141
+ onPreflightAccepted: () => {
9142
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
9143
+ if (options?.onPreflightAcceptCommit) return options.onPreflightAcceptCommit();
9144
+ options?.onPreflightAccepted?.();
9145
+ },
9146
+ });
9017
9147
  const terminalAssistant = this.#findLastAssistantMessage();
9018
9148
  if (
9019
9149
  rosterClaim &&
@@ -9048,14 +9178,7 @@ export class AgentSession {
9048
9178
  this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
9049
9179
  }
9050
9180
  this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
9051
- this.#endInFlight();
9052
- if (options?.skipPostPromptRecoveryWait) {
9053
- await this.#agentEndPublicationPromise;
9054
- } else {
9055
- await this.#agentEndHandlingPromise;
9056
- await this.#waitForPostPromptRecovery();
9057
- await this.#agentEndPublicationPromise;
9058
- }
9181
+ await this.#settleEndedInFlight(options?.skipPostPromptRecoveryWait ? "publication" : "full");
9059
9182
  }
9060
9183
  }
9061
9184
 
@@ -10712,19 +10835,56 @@ export class AgentSession {
10712
10835
  // Flush current session to ensure all entries are written
10713
10836
  await this.sessionManager.flush();
10714
10837
 
10715
- // Prepare the copied successor and complete local:// readiness while all
10716
- // public manager getters remain bound to the predecessor.
10717
- const prepared = await this.sessionManager.prepareFork();
10718
- if (!prepared) {
10719
- return false;
10720
- }
10721
- try {
10722
- await initializeLocalRoot(this.#localProtocolOptions(prepared));
10723
- await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
10724
- this.sessionManager.commitPreparedNewSession(prepared);
10838
+ const boundedColdForkEligible =
10839
+ this.sessionManager.getSessionMemoryStats().coldRetirementActive &&
10840
+ !this.sessionManager.isManagedDestination() &&
10841
+ previousSessionFile !== undefined;
10842
+ if (boundedColdForkEligible) {
10843
+ const previousManager = this.sessionManager;
10844
+ const forkedManager = await SessionManager.forkFrom(
10845
+ previousSessionFile,
10846
+ previousManager.getCwd(),
10847
+ SessionManager.explicitDestination(previousManager.getSessionDir()),
10848
+ undefined,
10849
+ "copy-retain",
10850
+ this.settings.get("sessionMemory.mode"),
10851
+ );
10852
+ try {
10853
+ await initializeLocalRoot({
10854
+ getArtifactsDir: () => forkedManager.getArtifactsDir(),
10855
+ isManagedDestination: () => false,
10856
+ getManagedLegacyLocalMigrationSource: () => null,
10857
+ getSessionId: () => forkedManager.getSessionId(),
10858
+ });
10859
+ await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
10860
+ } catch (error) {
10861
+ const forkedFile = forkedManager.getSessionFile();
10862
+ await forkedManager.close();
10863
+ if (forkedFile) await previousManager.discardUncommittedSession(forkedFile);
10864
+ throw error;
10865
+ }
10866
+ this.sessionManager = forkedManager;
10867
+ try {
10868
+ await previousManager.close();
10869
+ } catch (error) {
10870
+ logger.warn("Previous session close failed after bounded fork adoption", {
10871
+ error: error instanceof Error ? error.message : String(error),
10872
+ });
10873
+ }
10725
10874
  await this.#runToolSessionTransitionCleanups();
10726
- } catch (error) {
10727
- throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
10875
+ } else {
10876
+ // Prepare the copied successor and complete local:// readiness while all
10877
+ // public manager getters remain bound to the predecessor.
10878
+ const prepared = await this.sessionManager.prepareFork();
10879
+ if (!prepared) return false;
10880
+ try {
10881
+ await initializeLocalRoot(this.#localProtocolOptions(prepared));
10882
+ await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
10883
+ this.sessionManager.commitPreparedNewSession(prepared);
10884
+ await this.#runToolSessionTransitionCleanups();
10885
+ } catch (error) {
10886
+ throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
10887
+ }
10728
10888
  }
10729
10889
  this.#syncAgentSessionId();
10730
10890
  this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
@@ -13363,13 +13523,15 @@ export class AgentSession {
13363
13523
  }
13364
13524
  }
13365
13525
  }
13526
+ const sessionMemory = this.sessionManager.getSessionMemoryStats();
13366
13527
  return {
13367
13528
  heapUsedBytes: process.memoryUsage().heapUsed,
13368
13529
  providerBytes,
13369
13530
  messageCount: this.state.messages.length,
13370
13531
  imageBytes,
13371
13532
  sessionResidentImageBytes: this.sessionManager.getResidentImageBytes(),
13372
- materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
13533
+ materializedResidentBytes:
13534
+ this.#streamingEditFileCache.totalBytes + sessionMemory.hotRegionBytes + sessionMemory.metaDescriptorBytes,
13373
13535
  tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
13374
13536
  tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
13375
13537
  };
@@ -13777,11 +13939,6 @@ export class AgentSession {
13777
13939
  `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
13778
13940
  `</system-reminder>`;
13779
13941
 
13780
- logger.debug("Todo completion: sending reminder", {
13781
- incomplete: incomplete.length,
13782
- attempt: this.#todoReminderCount,
13783
- });
13784
-
13785
13942
  // Emit event for UI to render notification
13786
13943
  await this.#emitSessionEvent({
13787
13944
  type: "todo_reminder",
@@ -13790,7 +13947,27 @@ export class AgentSession {
13790
13947
  maxAttempts: remindersMax,
13791
13948
  });
13792
13949
 
13793
- // Inject reminder and continue the conversation
13950
+ // Consumers that cannot represent a server-initiated turn (ACP v1 clients, SDK
13951
+ // hosts) keep reporting the prompt as running until the terminal `agent_end` is
13952
+ // published. Injecting here would park that terminal behind a continuation hold
13953
+ // (`#scheduleAgentContinue` -> `#reserveDeferredAgentEndForContinuation`), so a
13954
+ // turn that already delivered its final answer would never settle — and there is
13955
+ // no interactive consumer to act on the nudge. Those hosts get the advisory
13956
+ // event above and nothing else.
13957
+ if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
13958
+ logger.debug("Todo completion: advisory reminder only", {
13959
+ incomplete: incomplete.length,
13960
+ attempt: this.#todoReminderCount,
13961
+ });
13962
+ return;
13963
+ }
13964
+
13965
+ logger.debug("Todo completion: sending reminder", {
13966
+ incomplete: incomplete.length,
13967
+ attempt: this.#todoReminderCount,
13968
+ });
13969
+
13970
+ // Inject reminder and continue conversation
13794
13971
  this.agent.appendMessage({
13795
13972
  role: "developer",
13796
13973
  content: [{ type: "text", text: reminder }],
@@ -15077,6 +15254,9 @@ export class AgentSession {
15077
15254
  )
15078
15255
  );
15079
15256
  }
15257
+ #isIdleStreamStallErrorMessage(errorMessage: string): boolean {
15258
+ return /stream stalled while waiting for the next event/i.test(errorMessage);
15259
+ }
15080
15260
 
15081
15261
  #isFirstEventTimeoutErrorMessage(errorMessage: string): boolean {
15082
15262
  // First-event timeout: the stream watchdog aborted because no event
@@ -15133,8 +15313,8 @@ export class AgentSession {
15133
15313
  /**
15134
15314
  * Ordered retry classification: typed safety stop (surface) -> legacy safety stop
15135
15315
  * (surface) -> overflow (compaction) -> terminal (surface) -> usage_limit
15136
- * (rotation) -> first_event_timeout (bounded retry) -> transient (unbounded retry) ->
15137
- * unknown (bounded retry).
15316
+ * (rotation) -> first_event_timeout (bounded retry) -> transient (unbounded retry,
15317
+ * except canonical idle-stream stalls bounded downstream) -> unknown (bounded retry).
15138
15318
  */
15139
15319
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
15140
15320
  if (message.stopReason !== "error") return "none";
@@ -15692,7 +15872,10 @@ export class AgentSession {
15692
15872
  return false;
15693
15873
  }
15694
15874
  }
15695
- const legacyUnbounded = !managedFallback && classification === "transient";
15875
+ const legacyUnbounded =
15876
+ !managedFallback &&
15877
+ classification === "transient" &&
15878
+ !this.#isIdleStreamStallErrorMessage(message.errorMessage ?? "");
15696
15879
  const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
15697
15880
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
15698
15881
  let outcome = managedFallback
@@ -15958,16 +16141,25 @@ export class AgentSession {
15958
16141
  }
15959
16142
 
15960
16143
  async #promptAgentWithIdleRetry(
15961
- messages: AgentMessage[],
16144
+ preSubmit: PreSubmitBuilder,
15962
16145
  options?: {
15963
16146
  toolChoice?: ToolChoice;
15964
16147
  fallbackManaged?: boolean;
15965
16148
  onRunAccepted?: (handle: AttemptRunHandle) => void;
15966
16149
  },
15967
16150
  predecessorAgentEndHold?: symbol,
16151
+ seam?: {
16152
+ signal?: AbortSignal;
16153
+ resourceRunId?: string;
16154
+ onPreflightAccepted?: () => void | Promise<void>;
16155
+ },
15968
16156
  ): Promise<void> {
15969
16157
  const deadline = Date.now() + 30_000;
15970
16158
  let continuationHold = predecessorAgentEndHold;
16159
+ // R3.2 helper-local compact-once flag: fresh per prompt; the inline retry
16160
+ // re-runs Phase B via preSubmit after the forced compaction.
16161
+ let overflowRetried = false;
16162
+ let preflightAccepted = false;
15971
16163
  for (;;) {
15972
16164
  try {
15973
16165
  const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(
@@ -15975,11 +16167,50 @@ export class AgentSession {
15975
16167
  );
15976
16168
  continuationHold = undefined;
15977
16169
  try {
16170
+ const messages = await preSubmit.build();
16171
+ if (messages === null) {
16172
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
16173
+ return;
16174
+ }
16175
+ if (!preflightAccepted) {
16176
+ await seam?.onPreflightAccepted?.();
16177
+ preflightAccepted = true;
16178
+ }
15978
16179
  await this.agent.prompt(messages, options);
15979
16180
  this.#releaseDeferredAgentEndLease(predecessorAgentEnd);
15980
16181
  return;
15981
16182
  } catch (error) {
15982
16183
  this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
16184
+ if (
16185
+ error instanceof SessionContextTooLargeError &&
16186
+ !overflowRetried &&
16187
+ this.settings.get("sessionMemory.contextOverflowRecovery")
16188
+ ) {
16189
+ // D7: exactly one forced, no-continuation compaction
16190
+ // (`willRetry:false`, `continueAfterMaintenance:false`), then
16191
+ // exactly one inline retry. Any other terminal status rethrows
16192
+ // the ORIGINAL typed error with measurements.
16193
+ overflowRetried = true;
16194
+ let compacted = false;
16195
+ try {
16196
+ const status = await this.#runAutoCompaction("overflow", false, false, {
16197
+ force: true,
16198
+ continueAfterMaintenance: false,
16199
+ signal: seam?.signal,
16200
+ resourceRunId: seam?.resourceRunId,
16201
+ });
16202
+ compacted = status.kind === "compacted";
16203
+ } catch {
16204
+ compacted = false;
16205
+ }
16206
+ if (compacted) {
16207
+ // Post-compaction Phase B must rebuild overlays (the
16208
+ // injected-context signatures were reset by
16209
+ // #applyCompactionPostAppend), so drop the cached attempt.
16210
+ preSubmit.reset();
16211
+ continue;
16212
+ }
16213
+ }
15983
16214
  throw error;
15984
16215
  }
15985
16216
  } catch (err) {
@@ -15990,6 +16221,7 @@ export class AgentSession {
15990
16221
  throw new Error("Timed out waiting for prior agent run to finish before prompting.");
15991
16222
  }
15992
16223
  await this.agent.waitForIdle();
16224
+ preSubmit.reset();
15993
16225
  }
15994
16226
  }
15995
16227
  }
@@ -16086,11 +16318,13 @@ export class AgentSession {
16086
16318
  * @param command The bash command to execute
16087
16319
  * @param onChunk Optional streaming callback for output
16088
16320
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
16321
+ * @param options.onPersisted Called once the execution's message is in session state
16322
+ * (immediately when idle, at the post-turn flush while streaming)
16089
16323
  */
16090
16324
  async executeBash(
16091
16325
  command: string,
16092
16326
  onChunk?: (chunk: string) => void,
16093
- options?: { excludeFromContext?: boolean },
16327
+ options?: { excludeFromContext?: boolean; onPersisted?: () => void },
16094
16328
  ): Promise<BashResult> {
16095
16329
  const excludeFromContext = options?.excludeFromContext === true;
16096
16330
  this.#markRetryReplayUnsafe();
@@ -16145,7 +16379,11 @@ export class AgentSession {
16145
16379
  * Record a bash execution result in session history.
16146
16380
  * Used by executeBash and by extensions that handle bash execution themselves.
16147
16381
  */
16148
- recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
16382
+ recordBashResult(
16383
+ command: string,
16384
+ result: BashResult,
16385
+ options?: { excludeFromContext?: boolean; onPersisted?: () => void },
16386
+ ): void {
16149
16387
  const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
16150
16388
  const bashMessage: BashExecutionMessage = {
16151
16389
  role: "bashExecution",
@@ -16162,13 +16400,18 @@ export class AgentSession {
16162
16400
  // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
16163
16401
  if (this.isStreaming) {
16164
16402
  // Queue for later - will be flushed on agent_end
16165
- this.#pendingBashMessages.push(bashMessage);
16403
+ this.#pendingBashMessages.push({
16404
+ message: bashMessage,
16405
+ onPersisted: options?.onPersisted,
16406
+ appendedToAgent: false,
16407
+ });
16166
16408
  } else {
16167
16409
  // Add to agent state immediately
16168
16410
  this.agent.appendMessage(bashMessage);
16169
16411
 
16170
16412
  // Save to session
16171
16413
  this.sessionManager.appendMessage(bashMessage);
16414
+ options?.onPersisted?.();
16172
16415
  }
16173
16416
  }
16174
16417
 
@@ -16196,17 +16439,7 @@ export class AgentSession {
16196
16439
  * Called after agent turn completes to maintain proper message ordering.
16197
16440
  */
16198
16441
  #flushPendingBashMessages(): void {
16199
- if (this.#pendingBashMessages.length === 0) return;
16200
-
16201
- for (const bashMessage of this.#pendingBashMessages) {
16202
- // Add to agent state
16203
- this.agent.appendMessage(bashMessage);
16204
-
16205
- // Save to session
16206
- this.sessionManager.appendMessage(bashMessage);
16207
- }
16208
-
16209
- this.#pendingBashMessages = [];
16442
+ this.#flushPendingExecutionMessages(this.#pendingBashMessages, "bash");
16210
16443
  }
16211
16444
 
16212
16445
  // =========================================================================
@@ -16219,11 +16452,13 @@ export class AgentSession {
16219
16452
  * @param code The Python code to execute
16220
16453
  * @param onChunk Optional streaming callback for output
16221
16454
  * @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
16455
+ * @param options.onPersisted Called once the execution's message is in session state
16456
+ * (immediately when idle, at the post-turn flush while streaming)
16222
16457
  */
16223
16458
  async executePython(
16224
16459
  code: string,
16225
16460
  onChunk?: (chunk: string) => void,
16226
- options?: { excludeFromContext?: boolean },
16461
+ options?: { excludeFromContext?: boolean; onPersisted?: () => void },
16227
16462
  ): Promise<PythonResult> {
16228
16463
  const excludeFromContext = options?.excludeFromContext === true;
16229
16464
  this.#markRetryReplayUnsafe();
@@ -16291,7 +16526,11 @@ export class AgentSession {
16291
16526
  /**
16292
16527
  * Record a Python execution result in session history.
16293
16528
  */
16294
- recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
16529
+ recordPythonResult(
16530
+ code: string,
16531
+ result: PythonResult,
16532
+ options?: { excludeFromContext?: boolean; onPersisted?: () => void },
16533
+ ): void {
16295
16534
  const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
16296
16535
  const pythonMessage: PythonExecutionMessage = {
16297
16536
  role: "pythonExecution",
@@ -16307,10 +16546,15 @@ export class AgentSession {
16307
16546
 
16308
16547
  // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
16309
16548
  if (this.isStreaming) {
16310
- this.#pendingPythonMessages.push(pythonMessage);
16549
+ this.#pendingPythonMessages.push({
16550
+ message: pythonMessage,
16551
+ onPersisted: options?.onPersisted,
16552
+ appendedToAgent: false,
16553
+ });
16311
16554
  } else {
16312
16555
  this.agent.appendMessage(pythonMessage);
16313
16556
  this.sessionManager.appendMessage(pythonMessage);
16557
+ options?.onPersisted?.();
16314
16558
  }
16315
16559
  }
16316
16560
 
@@ -16369,14 +16613,84 @@ export class AgentSession {
16369
16613
  * Flush pending Python messages to agent state and session.
16370
16614
  */
16371
16615
  #flushPendingPythonMessages(): void {
16372
- if (this.#pendingPythonMessages.length === 0) return;
16616
+ this.#flushPendingExecutionMessages(this.#pendingPythonMessages, "python");
16617
+ }
16373
16618
 
16374
- for (const pythonMessage of this.#pendingPythonMessages) {
16375
- this.agent.appendMessage(pythonMessage);
16376
- this.sessionManager.appendMessage(pythonMessage);
16619
+ #flushPendingExecutionMessages<T extends BashExecutionMessage | PythonExecutionMessage>(
16620
+ pendingMessages: Array<{ message: T; onPersisted?: () => void; appendedToAgent: boolean }>,
16621
+ kind: "bash" | "python",
16622
+ ): void {
16623
+ if (pendingMessages.length === 0) return;
16624
+
16625
+ const total = pendingMessages.length;
16626
+ const remaining: typeof pendingMessages = [];
16627
+ const errors: unknown[] = [];
16628
+ const persisted: string[] = [];
16629
+ let callbackFailureCount = 0;
16630
+ let persistenceBlocked = false;
16631
+ let agentAppendBlocked = false;
16632
+ for (const pending of pendingMessages) {
16633
+ if (!pending.appendedToAgent) {
16634
+ if (agentAppendBlocked) {
16635
+ remaining.push(pending);
16636
+ continue;
16637
+ }
16638
+ try {
16639
+ this.agent.appendMessage(pending.message);
16640
+ pending.appendedToAgent = true;
16641
+ } catch (error) {
16642
+ agentAppendBlocked = true;
16643
+ persistenceBlocked = true;
16644
+ remaining.push(pending);
16645
+ errors.push(error);
16646
+ continue;
16647
+ }
16648
+ }
16649
+
16650
+ if (persistenceBlocked) {
16651
+ remaining.push(pending);
16652
+ continue;
16653
+ }
16654
+
16655
+ try {
16656
+ this.sessionManager.appendMessage(pending.message);
16657
+ } catch (error) {
16658
+ // Session entries form a leaf-linked transcript. Once one append fails,
16659
+ // later entries must remain queued behind it or a retry would append the
16660
+ // failed entry after messages that originally followed it.
16661
+ persistenceBlocked = true;
16662
+ remaining.push(pending);
16663
+ errors.push(error);
16664
+ continue;
16665
+ }
16666
+
16667
+ persisted.push("command" in pending.message ? pending.message.command : pending.message.code);
16668
+ try {
16669
+ pending.onPersisted?.();
16670
+ } catch (error) {
16671
+ callbackFailureCount++;
16672
+ errors.push(error);
16673
+ }
16377
16674
  }
16378
16675
 
16379
- this.#pendingPythonMessages = [];
16676
+ pendingMessages.splice(0, pendingMessages.length, ...remaining);
16677
+ if (errors.length === 0) return;
16678
+
16679
+ const persistedCount = total - remaining.length;
16680
+ const persistenceFailureCount = remaining.length;
16681
+ const persistenceSummary =
16682
+ persistenceFailureCount > 0
16683
+ ? `Failed to persist ${persistenceFailureCount} of ${total} deferred ${kind} execution message${total === 1 ? "" : "s"}; ${persistedCount} persisted, failed messages remain pending for retry`
16684
+ : `Persisted all ${total} deferred ${kind} execution message${total === 1 ? "" : "s"}`;
16685
+ const callbackSummary =
16686
+ callbackFailureCount > 0
16687
+ ? `; ${callbackFailureCount} onPersisted callback${callbackFailureCount === 1 ? "" : "s"} failed`
16688
+ : "";
16689
+ const pending = remaining.map(item => ("command" in item.message ? item.message.command : item.message.code));
16690
+ throw new AggregateError(
16691
+ errors,
16692
+ `${persistenceSummary}${callbackSummary}; persisted: ${JSON.stringify(persisted)}; pending: ${JSON.stringify(pending)}`,
16693
+ );
16380
16694
  }
16381
16695
 
16382
16696
  // =========================================================================
@@ -17816,6 +18130,7 @@ export class AgentSession {
17816
18130
  cost: totalCost,
17817
18131
  ...(hasCompleteCostBreakdown ? { costBreakdown: totalCostBreakdown } : {}),
17818
18132
  premiumRequests: totalPremiumRequests,
18133
+ sessionMemory: this.sessionManager.getSessionMemoryStats(),
17819
18134
  };
17820
18135
  }
17821
18136