@gajae-code/coding-agent 0.11.5 → 0.11.6

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.
@@ -1420,6 +1420,11 @@ export class AgentSession {
1420
1420
  readonly notificationSessionController: NotificationSessionController | undefined;
1421
1421
  readonly taskDepth: number;
1422
1422
  readonly yieldQueue: YieldQueue;
1423
+ // True from the start of a handoff transition through commit/rollback. While
1424
+ // set, the yield queue treats the session as busy so background async-job
1425
+ // completions cannot start a new idle turn against the session being handed
1426
+ // off (or, on rollback, the restored predecessor mid-transition).
1427
+ #handoffTransitionActive = false;
1423
1428
 
1424
1429
  #powerAssertion: MacOSPowerAssertion | undefined;
1425
1430
 
@@ -1839,6 +1844,46 @@ export class AgentSession {
1839
1844
  });
1840
1845
  }
1841
1846
 
1847
+ /**
1848
+ * Reject a turn start while a handoff transition owns the session. Handoff never
1849
+ * routes its own generation/injection through these turn-start chokepoints, and
1850
+ * auto-maintenance runs before the fence, so this fences only external entrants
1851
+ * (steer/follow-up/sendUserMessage/custom/hidden-next-turn/continuation) that
1852
+ * bypass prompt admission.
1853
+ */
1854
+ #assertNoHandoffTransition(): void {
1855
+ if (this.#handoffTransitionActive) {
1856
+ throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
1857
+ code: "busy",
1858
+ });
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * Single, synchronously-acquired mutex for session-identity transitions
1864
+ * (handoff, compact, new/switch/branch/clear, fork, tree navigation). Acquired
1865
+ * BEFORE any await at each transition's entry and released in its finally, so
1866
+ * exclusion is symmetric regardless of which transition starts first — an
1867
+ * operation that began earlier and yielded still owns the lease when a peer
1868
+ * tries to start. Auto-handoff acquires it via handoff() (the maintenance
1869
+ * orchestrator does not hold it), so there is no self-deadlock.
1870
+ */
1871
+ #sessionTransitionKind: string | undefined;
1872
+
1873
+ #beginSessionTransition(kind: string): void {
1874
+ if (this.#sessionTransitionKind !== undefined) {
1875
+ throw Object.assign(
1876
+ new Error(`Cannot start ${kind} while a ${this.#sessionTransitionKind} transition is in progress.`),
1877
+ { code: "busy" },
1878
+ );
1879
+ }
1880
+ this.#sessionTransitionKind = kind;
1881
+ }
1882
+
1883
+ #endSessionTransition(): void {
1884
+ this.#sessionTransitionKind = undefined;
1885
+ }
1886
+
1842
1887
  #activateNextSessionAdmission(): void {
1843
1888
  if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
1844
1889
  const next = this.#sessionAdmissionQueue.shift();
@@ -1854,6 +1899,16 @@ export class AgentSession {
1854
1899
  const owner = this.#sessionAdmissionContext.getStore();
1855
1900
  if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
1856
1901
  if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
1902
+ // Reject new external turns for the whole handoff transition. The handoff
1903
+ // itself never acquires prompt admission (it generates via generateHandoff and
1904
+ // injects via appendCustomMessageEntry), so this fences external entrants —
1905
+ // prompt/sendUserMessage/steer/follow-up/triggerTurn all funnel here — without
1906
+ // blocking the handoff's own work or the exempt auto-maintenance owner.
1907
+ if (kind === "prompt" && this.#handoffTransitionActive) {
1908
+ throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
1909
+ code: "busy",
1910
+ });
1911
+ }
1857
1912
 
1858
1913
  const entry: SessionAdmissionEntry = {
1859
1914
  kind,
@@ -1871,6 +1926,17 @@ export class AgentSession {
1871
1926
  this.#activateNextSessionAdmission();
1872
1927
  throw this.#sessionAdmissionBusyError();
1873
1928
  }
1929
+ // Re-check the handoff fence after activation: a prompt queued before the
1930
+ // transition began must not start once the fence is up.
1931
+ if (kind === "prompt" && this.#handoffTransitionActive) {
1932
+ entry.released = true;
1933
+ entry.settled.resolve();
1934
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
1935
+ this.#activateNextSessionAdmission();
1936
+ throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
1937
+ code: "busy",
1938
+ });
1939
+ }
1874
1940
 
1875
1941
  const release = () => {
1876
1942
  if (entry.released) return;
@@ -2136,7 +2202,7 @@ export class AgentSession {
2136
2202
  this.#setGuardedAgentTools(this.agent.state.tools);
2137
2203
  this.#bindWorkflowGateEmitter();
2138
2204
  this.yieldQueue = new YieldQueue({
2139
- isStreaming: () => this.isStreaming,
2205
+ isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
2140
2206
  injectStreaming: message => this.agent.followUp(message),
2141
2207
  injectIdle: async messages => {
2142
2208
  const first = messages[0];
@@ -3772,7 +3838,7 @@ export class AgentSession {
3772
3838
  skipCompactionCheck?: boolean;
3773
3839
  suppressPredecessorAgentEnd?: boolean;
3774
3840
  shouldContinue?: () => boolean;
3775
- onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
3841
+ onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
3776
3842
  allowDuringCancelAndSubmit?: boolean;
3777
3843
  onError?: (error: unknown) => void;
3778
3844
  }): Promise<void> {
@@ -3780,7 +3846,7 @@ export class AgentSession {
3780
3846
  ? this.#reserveDeferredAgentEndForContinuation()
3781
3847
  : undefined;
3782
3848
  let terminalized = false;
3783
- const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained") => {
3849
+ const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => {
3784
3850
  if (terminalized) return;
3785
3851
  terminalized = true;
3786
3852
  this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
@@ -3836,6 +3902,13 @@ export class AgentSession {
3836
3902
  skip("queue_drained");
3837
3903
  return;
3838
3904
  }
3905
+ // A continuation scheduled before a handoff engaged must not start a
3906
+ // turn against the session being handed off (or the restored
3907
+ // predecessor). rearmIdle / normal delivery resumes after the fence.
3908
+ if (this.#handoffTransitionActive) {
3909
+ skip("handoff_in_progress");
3910
+ return;
3911
+ }
3839
3912
  const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
3840
3913
  try {
3841
3914
  await this.agent.continue(this.#managedFallbackPromptOptions());
@@ -6239,6 +6312,7 @@ export class AgentSession {
6239
6312
 
6240
6313
  /** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */
6241
6314
  async continuePersistedHistory(): Promise<void> {
6315
+ this.#assertNoHandoffTransition();
6242
6316
  this.#assertRecoveryHydrationPromoted();
6243
6317
  this.#removeEphemeralCustomMessages();
6244
6318
 
@@ -6275,6 +6349,10 @@ export class AgentSession {
6275
6349
  hindsightRecall = undefined;
6276
6350
  }
6277
6351
  }
6352
+ // Re-check after the awaited preparation: a handoff can engage during the
6353
+ // volatile-context/hindsight awaits above and this would otherwise start a
6354
+ // turn against the session being handed off.
6355
+ this.#assertNoHandoffTransition();
6278
6356
  await this.agent.continue({
6279
6357
  ...this.#managedFallbackPromptOptions(),
6280
6358
  onRunAccepted: () => {
@@ -6561,27 +6639,61 @@ export class AgentSession {
6561
6639
  return getAskAnswerSourceFromRegistry(this.sessionId);
6562
6640
  }
6563
6641
 
6564
- #bindWorkflowGateEmitter(previousSessionId?: string, previousEmitter = this.#workflowGateEmitter): void {
6642
+ #constructWorkflowGateEmitter(): WorkflowGateEmitter {
6565
6643
  const sessionId = this.sessionManager.getSessionId();
6566
6644
  assertNonEmptyGjcSessionId(sessionId, "AgentSession workflow-gate session");
6567
6645
  const gateStore = this.sessionManager.isPersisted()
6568
6646
  ? new FileGateStore(path.join(sessionStateDir(this.sessionManager.getCwd(), sessionId), "workflow-gates.json"))
6569
6647
  : new MemoryGateStore();
6570
- const successorEmitter = new BrokerWorkflowGateEmitter(sessionId, gateStore);
6571
- previousEmitter?.fence?.();
6572
- if (previousEmitter && !previousEmitter.fence) {
6573
- for (const gate of previousEmitter.listPendingGates?.() ?? []) previousEmitter.quarantineGate?.(gate.gate_id);
6648
+ return new BrokerWorkflowGateEmitter(sessionId, gateStore);
6649
+ }
6650
+
6651
+ /**
6652
+ * Publish an already-constructed successor emitter. This is no-throw from the
6653
+ * caller's perspective: predecessor fencing/quarantine is best-effort (a failure
6654
+ * must never leave the session with no emitter after a committed switch), and
6655
+ * listener notification is isolated in the registry.
6656
+ */
6657
+ #publishWorkflowGateEmitter(
6658
+ successorEmitter: WorkflowGateEmitter,
6659
+ previousSessionId?: string,
6660
+ previousEmitter = this.#workflowGateEmitter,
6661
+ ): void {
6662
+ try {
6663
+ previousEmitter?.fence?.();
6664
+ if (previousEmitter && !previousEmitter.fence) {
6665
+ for (const gate of previousEmitter.listPendingGates?.() ?? [])
6666
+ previousEmitter.quarantineGate?.(gate.gate_id);
6667
+ }
6668
+ } catch (error) {
6669
+ logger.warn("Workflow-gate predecessor fence failed during publish", {
6670
+ error: error instanceof Error ? error.message : String(error),
6671
+ });
6574
6672
  }
6575
6673
  if (previousSessionId) notifyWorkflowGateEmitterChanged(previousSessionId, undefined);
6576
6674
  this.setWorkflowGateEmitter(successorEmitter);
6577
6675
  }
6578
6676
 
6677
+ #bindWorkflowGateEmitter(previousSessionId?: string, previousEmitter = this.#workflowGateEmitter): void {
6678
+ this.#publishWorkflowGateEmitter(this.#constructWorkflowGateEmitter(), previousSessionId, previousEmitter);
6679
+ }
6680
+
6579
6681
  #suspendWorkflowGateEmitter(sessionId: string): WorkflowGateEmitter | undefined {
6580
6682
  const emitter = this.#workflowGateEmitter;
6581
6683
  if (!emitter) return undefined;
6582
- emitter.suspend?.();
6684
+ // Clear the field first, then run the (throwable) suspend + listener
6685
+ // notification inside a guard. This guarantees the caller always receives the
6686
+ // emitter token so a rollback can restore it, even if a registry listener
6687
+ // throws during notification.
6583
6688
  this.#workflowGateEmitter = undefined;
6584
- notifyWorkflowGateEmitterChanged(sessionId, undefined);
6689
+ try {
6690
+ emitter.suspend?.();
6691
+ notifyWorkflowGateEmitterChanged(sessionId, undefined);
6692
+ } catch (error) {
6693
+ logger.warn("Workflow-gate emitter suspension notification failed", {
6694
+ error: error instanceof Error ? error.message : String(error),
6695
+ });
6696
+ }
6585
6697
  return emitter;
6586
6698
  }
6587
6699
 
@@ -7290,7 +7402,12 @@ export class AgentSession {
7290
7402
  resetRetryReplaySafety?: boolean;
7291
7403
  },
7292
7404
  ): Promise<void> {
7405
+ this.#assertNoHandoffTransition();
7293
7406
  await this.#agentEndPublicationPromise;
7407
+ // Re-check after the publication await: a handoff can engage during that
7408
+ // window, and #beginInFlight below would otherwise start a turn against the
7409
+ // session being handed off.
7410
+ this.#assertNoHandoffTransition();
7294
7411
  this.#beginInFlight();
7295
7412
  const predecessorAgentEndHold =
7296
7413
  options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
@@ -7776,6 +7893,7 @@ export class AgentSession {
7776
7893
  images?: ImageContent[],
7777
7894
  options?: { claimsGenuineUserIntent?: boolean },
7778
7895
  ): Promise<void> {
7896
+ this.#assertNoHandoffTransition();
7779
7897
  assertImagePlaceholdersHavePayload(text, images);
7780
7898
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
7781
7899
  this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
@@ -7810,6 +7928,7 @@ export class AgentSession {
7810
7928
  images?: ImageContent[],
7811
7929
  options?: { forceOneAtATime?: boolean; claimsGenuineUserIntent?: boolean },
7812
7930
  ): Promise<void> {
7931
+ this.#assertNoHandoffTransition();
7813
7932
  assertImagePlaceholdersHavePayload(text, images);
7814
7933
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
7815
7934
  this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
@@ -7881,6 +8000,10 @@ export class AgentSession {
7881
8000
  }
7882
8001
 
7883
8002
  #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
8003
+ // A hidden next-turn message queued during a handoff transition would be
8004
+ // dropped when the successor clears predecessor queues; reject it as busy so
8005
+ // the caller can retry against the settled session.
8006
+ this.#assertNoHandoffTransition();
7884
8007
  this.#pendingNextTurnMessages.push(message);
7885
8008
  if (!triggerTurn) return;
7886
8009
  const generation = this.#promptGeneration;
@@ -8000,6 +8123,10 @@ export class AgentSession {
8000
8123
  this.#deepInterviewGenuineUserMessageEpochs.set(appMessage, epoch);
8001
8124
  }
8002
8125
  if (this.isStreaming) {
8126
+ // A handoff transition owns the session; a background/custom trigger (cron,
8127
+ // monitor, skill) must not steer/follow-up/queue against the outgoing turn
8128
+ // while an (auto-)handoff is unwinding it.
8129
+ this.#assertNoHandoffTransition();
8003
8130
  if (options?.deliverAs === "nextTurn") {
8004
8131
  this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
8005
8132
  return;
@@ -8725,12 +8852,15 @@ export class AgentSession {
8725
8852
  */
8726
8853
  newSession(options?: NewSessionOptions): Promise<boolean> {
8727
8854
  if (this.#newSessionTransition) return this.#newSessionTransition;
8728
-
8855
+ // Acquire the shared transition lease only when starting a fresh transition
8856
+ // (the dedup above returns the in-flight promise without re-acquiring).
8857
+ this.#beginSessionTransition("new-session");
8729
8858
  const transition = this.#runNewSessionTransition(options);
8730
8859
  this.#newSessionTransition = transition;
8731
8860
  void transition
8732
8861
  .finally(() => {
8733
8862
  if (this.#newSessionTransition === transition) this.#newSessionTransition = undefined;
8863
+ this.#endSessionTransition();
8734
8864
  })
8735
8865
  .catch(() => {});
8736
8866
  return transition;
@@ -8925,34 +9055,39 @@ export class AgentSession {
8925
9055
  * session identity and durable history trail.
8926
9056
  */
8927
9057
  async clearContext(): Promise<boolean> {
8928
- const sessionId = this.sessionId;
8929
- this.#disconnectFromAgent();
8930
- await this.abort();
8931
- this.#cancelOwnAsyncJobs();
8932
- this.#suppressOwnAsyncJobDeliveries();
8933
- this.yieldQueue.clear();
8934
- this.#pendingBackgroundExchanges = [];
8935
- this.#closeAllProviderSessions("context clear");
8936
- this.agent.reset();
8937
- await this.sessionManager.flush();
8938
- this.sessionManager.appendContextClearEntry({ sessionId });
8939
- this.setTodoPhases([]);
8940
- this.#syncAgentSessionId(sessionId);
8941
- this.#steeringMessages = [];
8942
- this.#followUpMessages = [];
8943
- this.#pendingNextTurnMessages = [];
8944
- this.#scheduledHiddenNextTurnGeneration = undefined;
9058
+ this.#beginSessionTransition("clear-context");
9059
+ try {
9060
+ const sessionId = this.sessionId;
9061
+ this.#disconnectFromAgent();
9062
+ await this.abort();
9063
+ this.#cancelOwnAsyncJobs();
9064
+ this.#suppressOwnAsyncJobDeliveries();
9065
+ this.yieldQueue.clear();
9066
+ this.#pendingBackgroundExchanges = [];
9067
+ this.#closeAllProviderSessions("context clear");
9068
+ this.agent.reset();
9069
+ await this.sessionManager.flush();
9070
+ this.sessionManager.appendContextClearEntry({ sessionId });
9071
+ this.setTodoPhases([]);
9072
+ this.#syncAgentSessionId(sessionId);
9073
+ this.#steeringMessages = [];
9074
+ this.#followUpMessages = [];
9075
+ this.#pendingNextTurnMessages = [];
9076
+ this.#scheduledHiddenNextTurnGeneration = undefined;
8945
9077
 
8946
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
8947
- if (this.model) {
8948
- this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
9078
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
9079
+ if (this.model) {
9080
+ this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
9081
+ }
9082
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
9083
+ this.#todoReminderCount = 0;
9084
+ this.#planReferenceSent = false;
9085
+ this.#planReferencePath = "local://PLAN.md";
9086
+ this.#reconnectToAgent();
9087
+ return true;
9088
+ } finally {
9089
+ this.#endSessionTransition();
8949
9090
  }
8950
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
8951
- this.#todoReminderCount = 0;
8952
- this.#planReferenceSent = false;
8953
- this.#planReferencePath = "local://PLAN.md";
8954
- this.#reconnectToAgent();
8955
- return true;
8956
9091
  }
8957
9092
 
8958
9093
  /**
@@ -8969,47 +9104,54 @@ export class AgentSession {
8969
9104
  * @returns true if completed, false if cancelled by hook or not persisting
8970
9105
  */
8971
9106
  async fork(): Promise<boolean> {
8972
- const previousSessionFile = this.sessionFile;
8973
- const previousWorkflowGateSessionId = this.sessionId;
9107
+ // Fork replaces session identity/file and publishes session_switch; serialize
9108
+ // it with handoff and the other transitions via the shared lease.
9109
+ this.#beginSessionTransition("fork");
9110
+ try {
9111
+ const previousSessionFile = this.sessionFile;
9112
+ const previousWorkflowGateSessionId = this.sessionId;
8974
9113
 
8975
- // Emit session_before_switch event with reason "fork" (can be cancelled)
8976
- if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
8977
- const result = (await this.#extensionRunner.emit({
8978
- type: "session_before_switch",
8979
- reason: "fork",
8980
- })) as SessionBeforeSwitchResult | undefined;
9114
+ // Emit session_before_switch event with reason "fork" (can be cancelled)
9115
+ if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
9116
+ const result = (await this.#extensionRunner.emit({
9117
+ type: "session_before_switch",
9118
+ reason: "fork",
9119
+ })) as SessionBeforeSwitchResult | undefined;
8981
9120
 
8982
- if (result?.cancel) {
8983
- return false;
9121
+ if (result?.cancel) {
9122
+ return false;
9123
+ }
8984
9124
  }
8985
- }
8986
9125
 
8987
- // Flush current session to ensure all entries are written
8988
- await this.sessionManager.flush();
9126
+ // Flush current session to ensure all entries are written
9127
+ await this.sessionManager.flush();
8989
9128
 
8990
- // Fork the session (creates new session file with same entries)
8991
- const forkResult = await this.sessionManager.fork();
8992
- if (!forkResult) {
8993
- return false;
8994
- }
9129
+ // Fork the session (creates new session file with same entries)
9130
+ const forkResult = await this.sessionManager.fork();
9131
+ if (!forkResult) {
9132
+ return false;
9133
+ }
8995
9134
 
8996
- // Update agent session ID
8997
- this.#syncAgentSessionId();
8998
- this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
8999
- this.#rekeyHindsightMemoryForCurrentSessionId();
9135
+ // Update agent session ID
9136
+ this.#syncAgentSessionId();
9137
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
9138
+ this.#rekeyHindsightMemoryForCurrentSessionId();
9000
9139
 
9001
- this.#resetIrcRosterDeliveryState();
9140
+ this.#resetIrcRosterDeliveryState();
9002
9141
 
9003
- // Emit session_switch event with reason "fork" to hooks
9004
- if (this.#extensionRunner) {
9005
- await this.#extensionRunner.emit({
9006
- type: "session_switch",
9007
- reason: "fork",
9008
- previousSessionFile,
9009
- });
9010
- }
9142
+ // Emit session_switch event with reason "fork" to hooks
9143
+ if (this.#extensionRunner) {
9144
+ await this.#extensionRunner.emit({
9145
+ type: "session_switch",
9146
+ reason: "fork",
9147
+ previousSessionFile,
9148
+ });
9149
+ }
9011
9150
 
9012
- return true;
9151
+ return true;
9152
+ } finally {
9153
+ this.#endSessionTransition();
9154
+ }
9013
9155
  }
9014
9156
 
9015
9157
  // =========================================================================
@@ -10009,148 +10151,156 @@ export class AgentSession {
10009
10151
  * @param options Optional callbacks for completion/error handling
10010
10152
  */
10011
10153
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
10012
- if (this.#compactionAbortController) {
10013
- throw new Error("Compaction already in progress");
10014
- }
10015
- this.#disconnectFromAgent();
10016
- await this.abort();
10017
- const compactionAbortController = new AbortController();
10018
- this.#compactionAbortController = compactionAbortController;
10019
-
10154
+ // Serialize with every other session-identity transition via the shared lease
10155
+ // (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
10156
+ // navigateTree). Released in the outer finally below.
10157
+ this.#beginSessionTransition("compact");
10020
10158
  try {
10021
- if (!this.model) {
10022
- throw new Error("No model selected");
10159
+ if (this.#compactionAbortController) {
10160
+ throw new Error("Compaction already in progress");
10023
10161
  }
10162
+ this.#disconnectFromAgent();
10163
+ await this.abort();
10164
+ const compactionAbortController = new AbortController();
10165
+ this.#compactionAbortController = compactionAbortController;
10024
10166
 
10025
- const compactionSettings = this.settings.getGroup("compaction");
10026
- const pathEntries = this.#withoutEphemeralCustomMessageEntries(this.sessionManager.getBranch());
10027
-
10028
- const preparation = prepareCompaction(pathEntries, compactionSettings, {
10029
- contextWindow: this.model.contextWindow,
10030
- tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
10031
- });
10032
- if (!preparation) {
10033
- // Check why we can't compact
10034
- const lastEntry = pathEntries[pathEntries.length - 1];
10035
- if (lastEntry?.type === "compaction") {
10036
- throw new Error("Already compacted");
10167
+ try {
10168
+ if (!this.model) {
10169
+ throw new Error("No model selected");
10037
10170
  }
10038
- throw new Error("Nothing to compact (session too small)");
10039
- }
10040
10171
 
10041
- let hookCompaction: CompactionResult | undefined;
10042
- let fromExtension = false;
10043
- let preserveData: Record<string, unknown> | undefined;
10172
+ const compactionSettings = this.settings.getGroup("compaction");
10173
+ const pathEntries = this.#withoutEphemeralCustomMessageEntries(this.sessionManager.getBranch());
10044
10174
 
10045
- if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
10046
- const result = (await this.#extensionRunner.emit({
10047
- type: "session_before_compact",
10048
- preparation,
10049
- branchEntries: pathEntries,
10050
- customInstructions,
10051
- signal: compactionAbortController.signal,
10052
- })) as SessionBeforeCompactResult | undefined;
10053
-
10054
- if (result?.cancel) {
10055
- throw new CompactionCancelledError();
10056
- }
10057
-
10058
- if (result?.compaction) {
10059
- hookCompaction = result.compaction;
10060
- fromExtension = true;
10175
+ const preparation = prepareCompaction(pathEntries, compactionSettings, {
10176
+ contextWindow: this.model.contextWindow,
10177
+ tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
10178
+ });
10179
+ if (!preparation) {
10180
+ // Check why we can't compact
10181
+ const lastEntry = pathEntries[pathEntries.length - 1];
10182
+ if (lastEntry?.type === "compaction") {
10183
+ throw new Error("Already compacted");
10184
+ }
10185
+ throw new Error("Nothing to compact (session too small)");
10061
10186
  }
10062
- }
10063
10187
 
10064
- const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
10065
-
10066
- let summary: string;
10067
- let shortSummary: string | undefined;
10068
- let firstKeptEntryId: string;
10069
- let tokensBefore: number;
10070
- let details: unknown;
10188
+ let hookCompaction: CompactionResult | undefined;
10189
+ let fromExtension = false;
10190
+ let preserveData: Record<string, unknown> | undefined;
10071
10191
 
10072
- if (compactionPrep.kind === "fromHook") {
10073
- summary = compactionPrep.summary;
10074
- shortSummary = compactionPrep.shortSummary;
10075
- firstKeptEntryId = compactionPrep.firstKeptEntryId;
10076
- tokensBefore = compactionPrep.tokensBefore;
10077
- details = compactionPrep.details;
10078
- preserveData = compactionPrep.preserveData;
10079
- } else {
10080
- // Generate compaction result. Only convert known abort-shaped
10081
- // rejections (AbortError raised while the abort signal is set,
10082
- // or an already-typed sentinel) into `CompactionCancelledError`
10083
- // so downstream callers can discriminate cancel from generic
10084
- // failure via `instanceof` without inspecting message strings.
10085
- // Real compaction bugs (network, server, parsing, etc.) keep
10086
- // their original shape — they must not be silently relabeled
10087
- // as cancellations even if the signal happens to be aborted
10088
- // for an unrelated reason. Assignments live inside the try
10089
- // block because every catch path throws — the post-try reads
10090
- // of the result-derived locals are reachable only on success.
10091
- try {
10092
- const result = await this.#compactWithFallbackModel(
10192
+ if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
10193
+ const result = (await this.#extensionRunner.emit({
10194
+ type: "session_before_compact",
10093
10195
  preparation,
10196
+ branchEntries: pathEntries,
10094
10197
  customInstructions,
10095
- compactionAbortController.signal,
10096
- {
10097
- promptOverride: compactionPrep.hookPrompt,
10098
- extraContext: compactionPrep.hookContext,
10099
- remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
10100
- convertToLlm,
10101
- },
10102
- );
10103
- summary = result.summary;
10104
- shortSummary = result.shortSummary;
10105
- firstKeptEntryId = result.firstKeptEntryId;
10106
- tokensBefore = result.tokensBefore;
10107
- details = result.details;
10108
- preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
10109
- } catch (err) {
10110
- if (err instanceof CompactionCancelledError) {
10111
- throw err;
10112
- }
10113
- if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
10198
+ signal: compactionAbortController.signal,
10199
+ })) as SessionBeforeCompactResult | undefined;
10200
+
10201
+ if (result?.cancel) {
10114
10202
  throw new CompactionCancelledError();
10115
10203
  }
10116
- throw err;
10204
+
10205
+ if (result?.compaction) {
10206
+ hookCompaction = result.compaction;
10207
+ fromExtension = true;
10208
+ }
10117
10209
  }
10118
- }
10119
10210
 
10120
- if (compactionAbortController.signal.aborted) {
10121
- throw new CompactionCancelledError();
10122
- }
10211
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
10212
+
10213
+ let summary: string;
10214
+ let shortSummary: string | undefined;
10215
+ let firstKeptEntryId: string;
10216
+ let tokensBefore: number;
10217
+ let details: unknown;
10218
+
10219
+ if (compactionPrep.kind === "fromHook") {
10220
+ summary = compactionPrep.summary;
10221
+ shortSummary = compactionPrep.shortSummary;
10222
+ firstKeptEntryId = compactionPrep.firstKeptEntryId;
10223
+ tokensBefore = compactionPrep.tokensBefore;
10224
+ details = compactionPrep.details;
10225
+ preserveData = compactionPrep.preserveData;
10226
+ } else {
10227
+ // Generate compaction result. Only convert known abort-shaped
10228
+ // rejections (AbortError raised while the abort signal is set,
10229
+ // or an already-typed sentinel) into `CompactionCancelledError`
10230
+ // so downstream callers can discriminate cancel from generic
10231
+ // failure via `instanceof` without inspecting message strings.
10232
+ // Real compaction bugs (network, server, parsing, etc.) keep
10233
+ // their original shape — they must not be silently relabeled
10234
+ // as cancellations even if the signal happens to be aborted
10235
+ // for an unrelated reason. Assignments live inside the try
10236
+ // block because every catch path throws — the post-try reads
10237
+ // of the result-derived locals are reachable only on success.
10238
+ try {
10239
+ const result = await this.#compactWithFallbackModel(
10240
+ preparation,
10241
+ customInstructions,
10242
+ compactionAbortController.signal,
10243
+ {
10244
+ promptOverride: compactionPrep.hookPrompt,
10245
+ extraContext: compactionPrep.hookContext,
10246
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
10247
+ convertToLlm,
10248
+ },
10249
+ );
10250
+ summary = result.summary;
10251
+ shortSummary = result.shortSummary;
10252
+ firstKeptEntryId = result.firstKeptEntryId;
10253
+ tokensBefore = result.tokensBefore;
10254
+ details = result.details;
10255
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
10256
+ } catch (err) {
10257
+ if (err instanceof CompactionCancelledError) {
10258
+ throw err;
10259
+ }
10260
+ if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
10261
+ throw new CompactionCancelledError();
10262
+ }
10263
+ throw err;
10264
+ }
10265
+ }
10123
10266
 
10124
- const compactionEntryId = this.sessionManager.appendCompaction(
10125
- summary,
10126
- shortSummary,
10127
- firstKeptEntryId,
10128
- tokensBefore,
10129
- details,
10130
- fromExtension,
10131
- preserveData,
10132
- );
10133
- await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
10267
+ if (compactionAbortController.signal.aborted) {
10268
+ throw new CompactionCancelledError();
10269
+ }
10134
10270
 
10135
- const compactionResult: CompactionResult = {
10136
- summary,
10137
- shortSummary,
10138
- firstKeptEntryId,
10139
- tokensBefore,
10140
- details,
10141
- preserveData,
10142
- };
10143
- options?.onComplete?.(compactionResult);
10144
- return compactionResult;
10145
- } catch (error) {
10146
- const err = error instanceof Error ? error : new Error(String(error));
10147
- options?.onError?.(err);
10148
- throw error;
10149
- } finally {
10150
- if (this.#compactionAbortController === compactionAbortController) {
10151
- this.#compactionAbortController = undefined;
10271
+ const compactionEntryId = this.sessionManager.appendCompaction(
10272
+ summary,
10273
+ shortSummary,
10274
+ firstKeptEntryId,
10275
+ tokensBefore,
10276
+ details,
10277
+ fromExtension,
10278
+ preserveData,
10279
+ );
10280
+ await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
10281
+
10282
+ const compactionResult: CompactionResult = {
10283
+ summary,
10284
+ shortSummary,
10285
+ firstKeptEntryId,
10286
+ tokensBefore,
10287
+ details,
10288
+ preserveData,
10289
+ };
10290
+ options?.onComplete?.(compactionResult);
10291
+ return compactionResult;
10292
+ } catch (error) {
10293
+ const err = error instanceof Error ? error : new Error(String(error));
10294
+ options?.onError?.(err);
10295
+ throw error;
10296
+ } finally {
10297
+ if (this.#compactionAbortController === compactionAbortController) {
10298
+ this.#compactionAbortController = undefined;
10299
+ }
10300
+ this.#reconnectToAgent();
10152
10301
  }
10153
- this.#reconnectToAgent();
10302
+ } finally {
10303
+ this.#endSessionTransition();
10154
10304
  }
10155
10305
  }
10156
10306
 
@@ -10214,6 +10364,8 @@ export class AgentSession {
10214
10364
  /** Trigger idle compaction through the auto-compaction flow (with UI events). */
10215
10365
  async runIdleCompaction(): Promise<void> {
10216
10366
  if (this.isStreaming || this.isCompacting) return;
10367
+ // Do not start idle compaction while a handoff transition owns the session.
10368
+ if (this.isGeneratingHandoff || this.#handoffTransitionActive) return;
10217
10369
  await this.#runAutoCompaction("idle", false, true);
10218
10370
  }
10219
10371
 
@@ -10252,8 +10404,37 @@ export class AgentSession {
10252
10404
  if (messageCount < 2) {
10253
10405
  throw new Error("Nothing to hand off (no messages yet)");
10254
10406
  }
10407
+ // Single-flight: a concurrent handoff would overwrite the abort controller
10408
+ // and the transition fence, letting one invocation's finally clear the
10409
+ // other's ownership and allowing overlapping newSession/restore transactions.
10410
+ if (this.isGeneratingHandoff || this.#handoffTransitionActive) {
10411
+ throw Object.assign(new Error("A handoff is already in progress."), { code: "busy" });
10412
+ }
10413
+ // A manual/external handoff must not race an active turn: replacing the
10414
+ // session and resetting the agent mid-stream can strand old-turn events
10415
+ // onto the successor session. Auto-triggered handoffs run during
10416
+ // post-turn maintenance (never mid-stream) and are exempt.
10417
+ if (this.isStreaming && !options?.autoTriggered) {
10418
+ throw Object.assign(
10419
+ new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
10420
+ { code: "busy" },
10421
+ );
10422
+ }
10423
+ // Acquire the shared session-transition lease so handoff is mutually exclusive
10424
+ // with compact/new/switch/branch/clear/fork/navigateTree in BOTH directions —
10425
+ // a transition that started first and yielded still owns the lease here, and a
10426
+ // peer that starts after us is rejected at its own entry. Auto-triggered
10427
+ // handoff still runs while auto-compaction owns its abort controller, but the
10428
+ // maintenance orchestrator does not hold this lease, so acquiring it here does
10429
+ // not self-deadlock. Released in the outer finally below.
10430
+ this.#beginSessionTransition("handoff");
10255
10431
 
10256
10432
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
10433
+ // Fence background async-job delivery for the whole transition (generation
10434
+ // through commit/rollback): a completion that lands mid-handoff must not
10435
+ // start an idle turn against the session being replaced or the restored
10436
+ // predecessor. Cleared in the finally below.
10437
+ this.#handoffTransitionActive = true;
10257
10438
 
10258
10439
  this.#handoffAbortController = new AbortController();
10259
10440
  const handoffAbortController = this.#handoffAbortController;
@@ -10294,6 +10475,7 @@ export class AgentSession {
10294
10475
  systemPrompt: this.#baseSystemPrompt,
10295
10476
  tools: this.agent.state.tools,
10296
10477
  customInstructions,
10478
+ promptExtension: this.settings.get("compaction.handoffPromptExtension") || undefined,
10297
10479
  convertToLlm,
10298
10480
  initiatorOverride: "agent",
10299
10481
  metadata: this.agent.metadataForProvider(model.provider),
@@ -10309,68 +10491,207 @@ export class AgentSession {
10309
10491
  return undefined;
10310
10492
  }
10311
10493
 
10312
- // Start a new session
10313
- const previousSessionFile = this.sessionFile;
10314
- await this.sessionManager.flush();
10315
- this.#cancelOwnAsyncJobs();
10316
- await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
10317
- this.agent.reset();
10318
- this.#syncAgentSessionId();
10319
- this.#rekeyHindsightMemoryForCurrentSessionId();
10320
- this.#resetHindsightConversationTrackingIfHindsight();
10321
- this.#steeringMessages = [];
10322
- this.#followUpMessages = [];
10323
- this.#pendingNextTurnMessages = [];
10324
- this.#scheduledHiddenNextTurnGeneration = undefined;
10325
- this.#todoReminderCount = 0;
10326
- if (model) {
10327
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
10494
+ // Revalidate immediately before mutating: generation can take seconds,
10495
+ // during which a turn (e.g. a background completion that started before
10496
+ // the delivery fence) may have begun. A manual/external handoff must not
10497
+ // mutate under an active turn; auto-triggered handoff runs at post-turn
10498
+ // maintenance and stays exempt. Nothing has been mutated yet, so throwing
10499
+ // here is fully non-destructive.
10500
+ if (this.isStreaming && !options?.autoTriggered) {
10501
+ throw Object.assign(
10502
+ new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
10503
+ // The document was already generated; retain it so public callers can
10504
+ // copy/retry even though this late race declines to mutate.
10505
+ { code: "busy", handoffDocument: handoffText },
10506
+ );
10328
10507
  }
10329
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
10330
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
10331
10508
 
10332
- // Inject the handoff document as a custom message
10333
- const handoffContent = createHandoffContext(handoffText);
10334
- this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
10335
- await this.sessionManager.ensureOnDisk();
10509
+ // Start a new session transactionally. Capture restore state before the
10510
+ // switch so a persistence, injection, display, or extension failure
10511
+ // after the switch is non-destructive: the current session stays active
10512
+ // and the generated handoff document is preserved for copy/retry.
10513
+ const previousSessionFile = this.sessionFile;
10514
+ await this.sessionManager.flush();
10515
+ const rollbackSessionState = this.sessionManager.captureState();
10516
+ const rollbackAgentMessages = [...this.agent.state.messages];
10517
+ const rollbackSteeringMessages = [...this.#steeringMessages];
10518
+ const rollbackFollowUpMessages = [...this.#followUpMessages];
10519
+ const rollbackPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
10520
+ const rollbackScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
10521
+ const rollbackTodoReminderCount = this.#todoReminderCount;
10522
+ // Snapshot the agent's executable queues so a rollback restores queued
10523
+ // user work that agent.reset() would otherwise clear.
10524
+ const rollbackAgentSteeringQueue = this.agent.snapshotSteering();
10525
+ const rollbackAgentFollowUpQueue = this.agent.snapshotFollowUp();
10526
+ let successorSessionFile: string | undefined;
10336
10527
  let savedPath: string | undefined;
10337
- if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
10338
- try {
10339
- const artifactId = await this.sessionManager.saveArtifact(`${handoffText}\n`, "handoff");
10340
- savedPath = `artifact://${artifactId}`;
10341
- } catch (error) {
10342
- logger.warn("Failed to save handoff document", {
10343
- error: error instanceof Error ? error.message : String(error),
10344
- });
10528
+ let committed = false;
10529
+ // Suspend (do not destroy) the workflow-gate emitter so it can be fenced on
10530
+ // commit or resumed on rollback. Suspension runs inside the transaction so a
10531
+ // listener fault during suspension is rolled back like any other prepare step.
10532
+ let suspendedWorkflowGateEmitter: WorkflowGateEmitter | undefined;
10533
+ try {
10534
+ suspendedWorkflowGateEmitter = this.#suspendWorkflowGateEmitter(rollbackSessionState.sessionId);
10535
+ // --- Prepare (reversible): build and persist the successor session
10536
+ // without touching predecessor authority. No irreversible predecessor
10537
+ // teardown or identity publication happens until the commit boundary.
10538
+ await this.sessionManager.newSession(
10539
+ previousSessionFile ? { parentSession: previousSessionFile } : undefined,
10540
+ );
10541
+ successorSessionFile = this.sessionFile;
10542
+ this.agent.reset();
10543
+ this.#syncAgentSessionId();
10544
+ this.#rekeyHindsightMemoryForCurrentSessionId();
10545
+ this.#steeringMessages = [];
10546
+ this.#followUpMessages = [];
10547
+ this.#pendingNextTurnMessages = [];
10548
+ this.#scheduledHiddenNextTurnGeneration = undefined;
10549
+ this.#todoReminderCount = 0;
10550
+ if (model) {
10551
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
10552
+ }
10553
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
10554
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
10555
+
10556
+ // Inject the handoff document as a custom message
10557
+ const handoffContent = createHandoffContext(handoffText);
10558
+ this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
10559
+ await this.sessionManager.ensureOnDisk();
10560
+ if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
10561
+ try {
10562
+ const artifactId = await this.sessionManager.saveArtifact(`${handoffText}\n`, "handoff");
10563
+ savedPath = `artifact://${artifactId}`;
10564
+ } catch (error) {
10565
+ logger.warn("Failed to save handoff document", {
10566
+ error: error instanceof Error ? error.message : String(error),
10567
+ });
10568
+ }
10345
10569
  }
10346
- }
10347
10570
 
10348
- // Rebuild agent messages from session
10349
- const sessionContext = this.buildDisplaySessionContext();
10350
- this.agent.replaceMessages(sessionContext.messages);
10351
- // Handoff rebuilds live context and can evict a previously injected
10352
- // goal/plan-mode-context copy; clear the static-once signatures.
10353
- this.#resetInjectedContextSignatures();
10354
- this.#syncTodoPhasesFromBranch();
10571
+ // Rebuild agent messages from session
10572
+ const sessionContext = this.buildDisplaySessionContext();
10573
+ this.agent.replaceMessages(sessionContext.messages);
10574
+ this.#syncTodoPhasesFromBranch();
10575
+
10576
+ // Construct the successor workflow-gate emitter in the reversible window:
10577
+ // FileGateStore load / broker init can throw, and a failure here must roll
10578
+ // back rather than corrupt an already-committed switch. Publication is
10579
+ // deferred to the (no-throw) commit step below.
10580
+ const successorGateEmitter = this.#constructWorkflowGateEmitter();
10581
+
10582
+ // --- Commit boundary: the successor is durably persisted and every
10583
+ // reversible, fallible step above has succeeded. Cross the irreversible
10584
+ // commit BEFORE any identity rotation. Fencing the predecessor gate
10585
+ // emitter and rotating provider sessions cannot be undone, so once we
10586
+ // begin them a failure must RETAIN the committed successor rather than
10587
+ // attempt a (now-impossible) rollback that would resume a fenced emitter.
10588
+ committed = true;
10589
+ this.#resetInjectedContextSignatures();
10590
+ this.#publishWorkflowGateEmitter(
10591
+ successorGateEmitter,
10592
+ rollbackSessionState.sessionId,
10593
+ suspendedWorkflowGateEmitter,
10594
+ );
10595
+ this.#closeAllProviderSessions("session handoff");
10596
+ this.#rebindProviderSessionState(new Map());
10597
+ this.#resetHindsightConversationTrackingIfHindsight();
10598
+ this.#resetIrcRosterDeliveryState();
10599
+ this.#planReferenceSent = false;
10600
+ this.#planReferencePath = "local://PLAN.md";
10601
+ this.#cancelOwnAsyncJobs();
10602
+ // The predecessor's fenced async-job results (queued while the
10603
+ // transition held the delivery fence) belong to the handed-off session,
10604
+ // not the successor. Suppress and drop ONLY the async-result kind so they
10605
+ // never flush into the new session; MCP resource notifications are
10606
+ // server-scoped and are preserved for the successor.
10607
+ this.#suppressOwnAsyncJobDeliveries();
10608
+ this.yieldQueue.clearKind("async-result");
10609
+
10610
+ // The successor identity/emitter/provider state is now fully live and
10611
+ // predecessor deliveries are suppressed, so release the turn-admission
10612
+ // fence BEFORE publishing session_switch. Successor turns (e.g. a
10613
+ // session_switch hook queuing steering) are legitimate and must not be
10614
+ // rejected; the finally below is only a backstop for early-exit paths.
10615
+ this.#handoffTransitionActive = false;
10616
+ // session_switch is a post-commit identity signal. Extension handler
10617
+ // errors are isolated by ExtensionRunner and must not roll back the
10618
+ // already-committed switch.
10619
+ if (this.#extensionRunner) {
10620
+ await this.#extensionRunner.emit({
10621
+ type: "session_switch",
10622
+ reason: "new",
10623
+ previousSessionFile,
10624
+ });
10625
+ }
10355
10626
 
10356
- this.#resetIrcRosterDeliveryState();
10357
- if (this.#extensionRunner) {
10358
- await this.#extensionRunner.emit({
10359
- type: "session_switch",
10360
- reason: "new",
10361
- previousSessionFile,
10627
+ return { document: handoffText, savedPath };
10628
+ } catch (switchError) {
10629
+ if (committed) {
10630
+ // The switch already committed; a post-commit step failed. Do not
10631
+ // tear down the new session — surface success (the handoff exists).
10632
+ logger.warn("Handoff post-commit step failed after the session switch committed", {
10633
+ error: switchError instanceof Error ? switchError.message : String(switchError),
10634
+ });
10635
+ return { document: handoffText, savedPath };
10636
+ }
10637
+ // Reversible window: roll back to the pre-handoff session so the
10638
+ // failure is non-destructive. Predecessor gate emitter, provider
10639
+ // sessions, async jobs, IRC/plan bookkeeping, and injection signatures
10640
+ // were never mutated before commit, so they survive intact.
10641
+ this.sessionManager.restoreState(rollbackSessionState);
10642
+ this.#syncAgentSessionId(rollbackSessionState.sessionId);
10643
+ this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
10644
+ this.#rekeyHindsightMemoryForCurrentSessionId();
10645
+ this.agent.replaceMessages(rollbackAgentMessages);
10646
+ this.agent.clearAllQueues();
10647
+ this.agent.restoreSteering(rollbackAgentSteeringQueue);
10648
+ this.agent.restoreFollowUp(rollbackAgentFollowUpQueue);
10649
+ this.#steeringMessages = rollbackSteeringMessages;
10650
+ this.#followUpMessages = rollbackFollowUpMessages;
10651
+ this.#pendingNextTurnMessages = rollbackPendingNextTurnMessages;
10652
+ this.#scheduledHiddenNextTurnGeneration = rollbackScheduledHiddenNextTurnGeneration;
10653
+ this.#todoReminderCount = rollbackTodoReminderCount;
10654
+ this.#syncTodoPhasesFromBranch();
10655
+ // Remove the orphaned successor transcript/artifacts written before the
10656
+ // failure so a rolled-back handoff leaves nothing behind.
10657
+ if (successorSessionFile && successorSessionFile !== previousSessionFile) {
10658
+ try {
10659
+ await this.sessionManager.discardUncommittedSession(successorSessionFile);
10660
+ } catch (cleanupError) {
10661
+ logger.warn("Failed to remove orphaned handoff session after rollback", {
10662
+ path: successorSessionFile,
10663
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
10664
+ });
10665
+ }
10666
+ }
10667
+ // Map to cancellation only for a genuine handoff-signal abort; a
10668
+ // downstream error keeps its cause and the generated document.
10669
+ if (handoffSignal.aborted) {
10670
+ throw new Error("Handoff cancelled");
10671
+ }
10672
+ // Preserve the generated handoff document for copy/retry.
10673
+ throw Object.assign(switchError instanceof Error ? switchError : new Error(String(switchError)), {
10674
+ handoffDocument: handoffText,
10362
10675
  });
10363
10676
  }
10364
-
10365
- return { document: handoffText, savedPath };
10366
10677
  } catch (error) {
10367
- if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
10678
+ // Genuine handoff-signal cancellation maps to a cancellation error.
10679
+ // Errors surfaced by the inner transaction (which already rolled back
10680
+ // and, for non-aborts, attached the generated document) pass through.
10681
+ if (handoffSignal.aborted) {
10368
10682
  throw new Error("Handoff cancelled");
10369
10683
  }
10370
10684
  throw error;
10371
10685
  } finally {
10372
10686
  sourceSignal?.removeEventListener("abort", onSourceAbort);
10373
10687
  this.#handoffAbortController = undefined;
10688
+ this.#handoffTransitionActive = false;
10689
+ // Releasing the fence: re-arm any idle delivery queued while the transition
10690
+ // held it (e.g. a predecessor async result retained through a rollback, or a
10691
+ // preserved MCP notification) so it is not stranded until an unrelated
10692
+ // enqueue or the next agent yield.
10693
+ this.yieldQueue.rearmIdle();
10694
+ this.#endSessionTransition();
10374
10695
  }
10375
10696
  }
10376
10697
 
@@ -13046,6 +13367,9 @@ export class AgentSession {
13046
13367
  */
13047
13368
  async retry(): Promise<boolean> {
13048
13369
  if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
13370
+ // A handoff transition owns the session; retrying would mutate the tail and
13371
+ // schedule a continuation against the session being handed off.
13372
+ if (this.isGeneratingHandoff || this.#handoffTransitionActive) return false;
13049
13373
 
13050
13374
  const messages = this.agent.state.messages;
13051
13375
  const lastMsg = messages[messages.length - 1];
@@ -13961,201 +14285,212 @@ export class AgentSession {
13961
14285
  * @returns true if switch completed, false if cancelled by hook
13962
14286
  */
13963
14287
  async switchSession(sessionPath: string): Promise<boolean> {
13964
- const previousSessionFile = this.sessionManager.getSessionFile();
13965
- const switchingToDifferentSession = previousSessionFile
13966
- ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
13967
- : true;
13968
- // Emit session_before_switch event (can be cancelled)
13969
- if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
13970
- const result = (await this.#extensionRunner.emit({
13971
- type: "session_before_switch",
13972
- reason: "resume",
13973
- targetSessionFile: sessionPath,
13974
- })) as SessionBeforeSwitchResult | undefined;
14288
+ this.#beginSessionTransition("switch-session");
14289
+ try {
14290
+ const previousSessionFile = this.sessionManager.getSessionFile();
14291
+ const switchingToDifferentSession = previousSessionFile
14292
+ ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
14293
+ : true;
14294
+ // Emit session_before_switch event (can be cancelled)
14295
+ if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
14296
+ const result = (await this.#extensionRunner.emit({
14297
+ type: "session_before_switch",
14298
+ reason: "resume",
14299
+ targetSessionFile: sessionPath,
14300
+ })) as SessionBeforeSwitchResult | undefined;
13975
14301
 
13976
- if (result?.cancel) {
13977
- return false;
14302
+ if (result?.cancel) {
14303
+ return false;
14304
+ }
13978
14305
  }
13979
- }
13980
14306
 
13981
- this.#disconnectFromAgent();
13982
- await this.abort();
13983
-
13984
- // Flush pending writes before switching so restore snapshots reflect committed state.
13985
- await this.sessionManager.flush();
13986
- const previousSessionState = this.sessionManager.captureState();
13987
- const previousSessionContext = this.buildDisplaySessionContext();
13988
- // switchSession replaces these arrays wholesale during load/rollback, so retaining
13989
- // the existing message objects is sufficient and avoids structured-clone failures for
13990
- // extension/custom metadata that is valid to persist but not cloneable.
13991
- const previousAgentMessages = [...this.agent.state.messages];
13992
- const previousSteeringMessages = [...this.#steeringMessages];
13993
- const previousFollowUpMessages = [...this.#followUpMessages];
13994
- const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
13995
- const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
13996
- const previousModel = this.model;
13997
- const previousThinkingLevel = this.#thinkingLevel;
13998
- const previousServiceTier = this.agent.serviceTier;
13999
- const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
14000
- const previousTools = [...this.agent.state.tools];
14001
- const previousBaseSystemPrompt = this.#baseSystemPrompt;
14002
- const previousSystemPrompt = this.agent.state.systemPrompt;
14003
- const previousAgentSteeringQueue = this.agent.snapshotSteering();
14004
- const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
14307
+ this.#disconnectFromAgent();
14308
+ await this.abort();
14005
14309
 
14006
- this.#steeringMessages = [];
14007
- this.#followUpMessages = [];
14008
- this.#pendingNextTurnMessages = [];
14009
- this.#scheduledHiddenNextTurnGeneration = undefined;
14010
- const suspendedWorkflowGateEmitter = switchingToDifferentSession
14011
- ? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
14012
- : undefined;
14013
- let unavailableDefaultChainMessage: string | undefined;
14310
+ // Flush pending writes before switching so restore snapshots reflect committed state.
14311
+ await this.sessionManager.flush();
14312
+ const previousSessionState = this.sessionManager.captureState();
14313
+ const previousSessionContext = this.buildDisplaySessionContext();
14314
+ // switchSession replaces these arrays wholesale during load/rollback, so retaining
14315
+ // the existing message objects is sufficient and avoids structured-clone failures for
14316
+ // extension/custom metadata that is valid to persist but not cloneable.
14317
+ const previousAgentMessages = [...this.agent.state.messages];
14318
+ const previousSteeringMessages = [...this.#steeringMessages];
14319
+ const previousFollowUpMessages = [...this.#followUpMessages];
14320
+ const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
14321
+ const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
14322
+ const previousModel = this.model;
14323
+ const previousThinkingLevel = this.#thinkingLevel;
14324
+ const previousServiceTier = this.agent.serviceTier;
14325
+ const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
14326
+ const previousTools = [...this.agent.state.tools];
14327
+ const previousBaseSystemPrompt = this.#baseSystemPrompt;
14328
+ const previousSystemPrompt = this.agent.state.systemPrompt;
14329
+ const previousAgentSteeringQueue = this.agent.snapshotSteering();
14330
+ const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
14014
14331
 
14015
- try {
14016
- await this.sessionManager.setSessionFile(sessionPath);
14017
- this.#syncAgentSessionId();
14018
- this.#rekeyHindsightMemoryForCurrentSessionId();
14332
+ this.#steeringMessages = [];
14333
+ this.#followUpMessages = [];
14334
+ this.#pendingNextTurnMessages = [];
14335
+ this.#scheduledHiddenNextTurnGeneration = undefined;
14336
+ const suspendedWorkflowGateEmitter = switchingToDifferentSession
14337
+ ? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
14338
+ : undefined;
14339
+ let unavailableDefaultChainMessage: string | undefined;
14019
14340
 
14020
- const sessionContext = this.buildDisplaySessionContext();
14021
- const didReloadConversationChange =
14022
- !switchingToDifferentSession &&
14023
- this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
14024
- await this.#restoreMCPSelectionsForSessionContext(sessionContext);
14341
+ try {
14342
+ await this.sessionManager.setSessionFile(sessionPath);
14343
+ this.#syncAgentSessionId();
14344
+ this.#rekeyHindsightMemoryForCurrentSessionId();
14025
14345
 
14026
- // The target session is loaded and MCP selections are restored: discard
14027
- // pre-switch delivery queues before completing the restored agent state.
14028
- this.agent.clearAllQueues();
14346
+ const sessionContext = this.buildDisplaySessionContext();
14347
+ const didReloadConversationChange =
14348
+ !switchingToDifferentSession &&
14349
+ this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
14350
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext);
14029
14351
 
14030
- this.agent.replaceMessages(sessionContext.messages);
14031
- this.#resetInjectedContextSignatures();
14032
- this.#syncTodoPhasesFromBranch();
14033
- if (switchingToDifferentSession || didReloadConversationChange) {
14034
- this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
14035
- this.#rebindProviderSessionState(new Map());
14036
- }
14352
+ // The target session is loaded and MCP selections are restored: discard
14353
+ // pre-switch delivery queues before completing the restored agent state.
14354
+ this.agent.clearAllQueues();
14037
14355
 
14038
- const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
14039
- const defaultEntries =
14040
- configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
14041
- this.#defaultFallbackController = undefined;
14042
- if (defaultEntries.length > 0) {
14043
- const resolution = await resolveModelChainWithAuth(
14044
- defaultEntries,
14045
- this.#modelRegistry,
14046
- this.settings,
14047
- this.sessionId,
14048
- { managedFallback: true },
14049
- );
14050
- const controller = this.#defaultFallbackChain();
14051
- this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
14052
- if (!resolution.model) {
14053
- unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
14054
- throw new Error(unavailableDefaultChainMessage);
14055
- }
14056
- if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
14057
- this.#setModelAuthoritatively(resolution.model, "restore");
14058
- }
14059
- if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
14060
- this.setThinkingLevel(resolution.thinkingLevel);
14061
- }
14062
- }
14063
-
14064
- const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
14065
- const hasServiceTierEntry = this.sessionManager
14066
- .getBranch()
14067
- .some(entry => entry.type === "service_tier_change");
14068
- const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
14069
- const configuredServiceTier = this.settings.get("serviceTier");
14070
- const persistedThinkingLevel = hasThinkingEntry
14071
- ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
14072
- : defaultThinkingLevel;
14073
- const nextThinkingLevel = resolveThinkingLevelForModel(
14074
- this.model,
14075
- persistedThinkingLevel === ThinkingLevel.Inherit
14076
- ? this.#getInheritedThinkingLevel()
14077
- : persistedThinkingLevel,
14078
- );
14079
- this.#thinkingLevel = nextThinkingLevel;
14080
- this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
14081
- this.agent.serviceTier = hasServiceTierEntry
14082
- ? sessionContext.serviceTier
14083
- : configuredServiceTier === "none"
14084
- ? undefined
14085
- : configuredServiceTier;
14086
- // Establish the successor's durable session identity only after every
14087
- // restored state facet is live. Identity-bound extension hooks run below.
14088
- await this.sessionManager.ensureOnDisk();
14356
+ this.agent.replaceMessages(sessionContext.messages);
14357
+ this.#resetInjectedContextSignatures();
14358
+ this.#syncTodoPhasesFromBranch();
14359
+ if (switchingToDifferentSession || didReloadConversationChange) {
14360
+ this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
14361
+ this.#rebindProviderSessionState(new Map());
14362
+ }
14089
14363
 
14090
- if (switchingToDifferentSession) {
14091
- this.#resetHindsightConversationTrackingIfHindsight();
14092
- this.#resetIrcRosterDeliveryState();
14093
- }
14364
+ const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
14365
+ const defaultEntries =
14366
+ configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
14367
+ this.#defaultFallbackController = undefined;
14368
+ if (defaultEntries.length > 0) {
14369
+ const resolution = await resolveModelChainWithAuth(
14370
+ defaultEntries,
14371
+ this.#modelRegistry,
14372
+ this.settings,
14373
+ this.sessionId,
14374
+ { managedFallback: true },
14375
+ );
14376
+ const controller = this.#defaultFallbackChain();
14377
+ this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
14378
+ if (!resolution.model) {
14379
+ unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
14380
+ throw new Error(unavailableDefaultChainMessage);
14381
+ }
14382
+ if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
14383
+ this.#setModelAuthoritatively(resolution.model, "restore");
14384
+ }
14385
+ if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
14386
+ this.setThinkingLevel(resolution.thinkingLevel);
14387
+ }
14388
+ }
14094
14389
 
14095
- this.#reconnectToAgent();
14096
- // Fence predecessor continuations before session_switch starts SDK runtime
14097
- // teardown. The previous runtime waits for those continuations to settle;
14098
- // waiting to transfer authority until after hooks creates a circular wait.
14099
- if (suspendedWorkflowGateEmitter)
14100
- this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
14101
- // session_switch is the post-commit identity signal. SDK authority and
14102
- // other identity-bound integrations must not observe the successor until
14103
- // messages, model state, MCP selections, and the agent subscription are live.
14104
- if (this.#extensionRunner) {
14105
- await this.#extensionRunner.emit({
14106
- type: "session_switch",
14107
- reason: "resume",
14108
- previousSessionFile,
14109
- });
14110
- }
14111
- return true;
14112
- } catch (error) {
14113
- this.sessionManager.restoreState(previousSessionState);
14114
- this.#defaultFallbackController = undefined;
14115
- this.#syncAgentSessionId(previousSessionState.sessionId);
14116
- this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
14117
- this.#rekeyHindsightMemoryForCurrentSessionId();
14118
- let restoreMcpError: unknown;
14119
- try {
14120
- await this.#restoreMCPSelectionsForSessionContext(previousSessionContext);
14121
- } catch (mcpError) {
14122
- restoreMcpError = mcpError;
14123
- logger.warn("Failed to restore MCP selections after switch error", {
14124
- previousSessionFile,
14125
- targetSessionFile: sessionPath,
14126
- error: String(mcpError),
14127
- });
14128
- this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
14129
- this.#setGuardedAgentTools(previousTools);
14390
+ const hasThinkingEntry = this.sessionManager
14391
+ .getBranch()
14392
+ .some(entry => entry.type === "thinking_level_change");
14393
+ const hasServiceTierEntry = this.sessionManager
14394
+ .getBranch()
14395
+ .some(entry => entry.type === "service_tier_change");
14396
+ const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
14397
+ const configuredServiceTier = this.settings.get("serviceTier");
14398
+ const persistedThinkingLevel = hasThinkingEntry
14399
+ ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
14400
+ : defaultThinkingLevel;
14401
+ const nextThinkingLevel = resolveThinkingLevelForModel(
14402
+ this.model,
14403
+ persistedThinkingLevel === ThinkingLevel.Inherit
14404
+ ? this.#getInheritedThinkingLevel()
14405
+ : persistedThinkingLevel,
14406
+ );
14407
+ this.#thinkingLevel = nextThinkingLevel;
14408
+ this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
14409
+ this.agent.serviceTier = hasServiceTierEntry
14410
+ ? sessionContext.serviceTier
14411
+ : configuredServiceTier === "none"
14412
+ ? undefined
14413
+ : configuredServiceTier;
14414
+ // Establish the successor's durable session identity only after every
14415
+ // restored state facet is live. Identity-bound extension hooks run below.
14416
+ await this.sessionManager.ensureOnDisk();
14417
+
14418
+ if (switchingToDifferentSession) {
14419
+ this.#resetHindsightConversationTrackingIfHindsight();
14420
+ this.#resetIrcRosterDeliveryState();
14421
+ }
14422
+
14423
+ this.#reconnectToAgent();
14424
+ // Fence predecessor continuations before session_switch starts SDK runtime
14425
+ // teardown. The previous runtime waits for those continuations to settle;
14426
+ // waiting to transfer authority until after hooks creates a circular wait.
14427
+ if (suspendedWorkflowGateEmitter)
14428
+ this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
14429
+ // session_switch is the post-commit identity signal. SDK authority and
14430
+ // other identity-bound integrations must not observe the successor until
14431
+ // messages, model state, MCP selections, and the agent subscription are live.
14432
+ if (this.#extensionRunner) {
14433
+ await this.#extensionRunner.emit({
14434
+ type: "session_switch",
14435
+ reason: "resume",
14436
+ previousSessionFile,
14437
+ });
14438
+ }
14439
+ return true;
14440
+ } catch (error) {
14441
+ this.sessionManager.restoreState(previousSessionState);
14442
+ this.#defaultFallbackController = undefined;
14443
+ this.#syncAgentSessionId(previousSessionState.sessionId);
14444
+ this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
14445
+ this.#rekeyHindsightMemoryForCurrentSessionId();
14446
+ let restoreMcpError: unknown;
14447
+ try {
14448
+ await this.#restoreMCPSelectionsForSessionContext(previousSessionContext);
14449
+ } catch (mcpError) {
14450
+ restoreMcpError = mcpError;
14451
+ logger.warn("Failed to restore MCP selections after switch error", {
14452
+ previousSessionFile,
14453
+ targetSessionFile: sessionPath,
14454
+ error: String(mcpError),
14455
+ });
14456
+ this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
14457
+ this.#setGuardedAgentTools(previousTools);
14458
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
14459
+ this.agent.setSystemPrompt(previousSystemPrompt);
14460
+ }
14130
14461
  this.#baseSystemPrompt = previousBaseSystemPrompt;
14131
14462
  this.agent.setSystemPrompt(previousSystemPrompt);
14463
+ this.agent.replaceMessages(previousAgentMessages);
14464
+ this.#steeringMessages = previousSteeringMessages;
14465
+ this.#followUpMessages = previousFollowUpMessages;
14466
+ this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
14467
+ this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
14468
+ this.agent.clearAllQueues();
14469
+ this.agent.restoreSteering(previousAgentSteeringQueue);
14470
+ this.agent.restoreFollowUp(previousAgentFollowUpQueue);
14471
+ if (previousModel) {
14472
+ this.agent.setModel(previousModel);
14473
+ }
14474
+ this.#thinkingLevel = previousThinkingLevel;
14475
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
14476
+ this.agent.serviceTier = previousServiceTier;
14477
+ this.#syncTodoPhasesFromBranch();
14478
+ this.#reconnectToAgent();
14479
+ if (restoreMcpError) {
14480
+ throw restoreMcpError;
14481
+ }
14482
+ if (unavailableDefaultChainMessage) {
14483
+ this.emitNotice(
14484
+ "error",
14485
+ `Could not restore session model: ${unavailableDefaultChainMessage}`,
14486
+ "fallback",
14487
+ );
14488
+ return false;
14489
+ }
14490
+ throw error;
14132
14491
  }
14133
- this.#baseSystemPrompt = previousBaseSystemPrompt;
14134
- this.agent.setSystemPrompt(previousSystemPrompt);
14135
- this.agent.replaceMessages(previousAgentMessages);
14136
- this.#steeringMessages = previousSteeringMessages;
14137
- this.#followUpMessages = previousFollowUpMessages;
14138
- this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
14139
- this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
14140
- this.agent.clearAllQueues();
14141
- this.agent.restoreSteering(previousAgentSteeringQueue);
14142
- this.agent.restoreFollowUp(previousAgentFollowUpQueue);
14143
- if (previousModel) {
14144
- this.agent.setModel(previousModel);
14145
- }
14146
- this.#thinkingLevel = previousThinkingLevel;
14147
- this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
14148
- this.agent.serviceTier = previousServiceTier;
14149
- this.#syncTodoPhasesFromBranch();
14150
- this.#reconnectToAgent();
14151
- if (restoreMcpError) {
14152
- throw restoreMcpError;
14153
- }
14154
- if (unavailableDefaultChainMessage) {
14155
- this.emitNotice("error", `Could not restore session model: ${unavailableDefaultChainMessage}`, "fallback");
14156
- return false;
14157
- }
14158
- throw error;
14492
+ } finally {
14493
+ this.#endSessionTransition();
14159
14494
  }
14160
14495
  }
14161
14496
 
@@ -14172,74 +14507,79 @@ export class AgentSession {
14172
14507
  selectedText: string;
14173
14508
  cancelled: boolean;
14174
14509
  }> {
14175
- const previousSessionFile = this.sessionFile;
14176
- const previousWorkflowGateSessionId = this.sessionId;
14177
- const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
14510
+ this.#beginSessionTransition("branch");
14511
+ try {
14512
+ const previousSessionFile = this.sessionFile;
14513
+ const previousWorkflowGateSessionId = this.sessionId;
14514
+ const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
14178
14515
 
14179
- if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
14180
- throw new Error("Invalid entry ID for branching");
14181
- }
14516
+ if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
14517
+ throw new Error("Invalid entry ID for branching");
14518
+ }
14182
14519
 
14183
- const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
14520
+ const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
14184
14521
 
14185
- let skipConversationRestore = false;
14522
+ let skipConversationRestore = false;
14186
14523
 
14187
- // Emit session_before_branch event (can be cancelled)
14188
- if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
14189
- const result = (await this.#extensionRunner.emit({
14190
- type: "session_before_branch",
14191
- entryId,
14192
- })) as SessionBeforeBranchResult | undefined;
14524
+ // Emit session_before_branch event (can be cancelled)
14525
+ if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
14526
+ const result = (await this.#extensionRunner.emit({
14527
+ type: "session_before_branch",
14528
+ entryId,
14529
+ })) as SessionBeforeBranchResult | undefined;
14193
14530
 
14194
- if (result?.cancel) {
14195
- return { selectedText, cancelled: true };
14531
+ if (result?.cancel) {
14532
+ return { selectedText, cancelled: true };
14533
+ }
14534
+ skipConversationRestore = result?.skipConversationRestore ?? false;
14196
14535
  }
14197
- skipConversationRestore = result?.skipConversationRestore ?? false;
14198
- }
14199
14536
 
14200
- // Clear pending messages (bound to old session state)
14201
- this.#pendingNextTurnMessages = [];
14202
- this.#scheduledHiddenNextTurnGeneration = undefined;
14537
+ // Clear pending messages (bound to old session state)
14538
+ this.#pendingNextTurnMessages = [];
14539
+ this.#scheduledHiddenNextTurnGeneration = undefined;
14203
14540
 
14204
- // Flush pending writes before branching
14205
- await this.sessionManager.flush();
14206
- this.#cancelOwnAsyncJobs();
14541
+ // Flush pending writes before branching
14542
+ await this.sessionManager.flush();
14543
+ this.#cancelOwnAsyncJobs();
14207
14544
 
14208
- if (!selectedEntry.parentId) {
14209
- await this.sessionManager.newSession({ parentSession: previousSessionFile });
14210
- } else {
14211
- this.sessionManager.createBranchedSession(selectedEntry.parentId);
14212
- }
14213
- this.#syncTodoPhasesFromBranch();
14214
- this.#syncAgentSessionId();
14215
- this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
14216
- this.#rekeyHindsightMemoryForCurrentSessionId();
14217
- this.#resetHindsightConversationTrackingIfHindsight();
14218
- this.#closeAllProviderSessions("session branch");
14219
- this.#rebindProviderSessionState(new Map());
14545
+ if (!selectedEntry.parentId) {
14546
+ await this.sessionManager.newSession({ parentSession: previousSessionFile });
14547
+ } else {
14548
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
14549
+ }
14550
+ this.#syncTodoPhasesFromBranch();
14551
+ this.#syncAgentSessionId();
14552
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
14553
+ this.#rekeyHindsightMemoryForCurrentSessionId();
14554
+ this.#resetHindsightConversationTrackingIfHindsight();
14555
+ this.#closeAllProviderSessions("session branch");
14556
+ this.#rebindProviderSessionState(new Map());
14220
14557
 
14221
- // Reload messages from entries (works for both file and in-memory mode)
14222
- const sessionContext = this.buildDisplaySessionContext();
14558
+ // Reload messages from entries (works for both file and in-memory mode)
14559
+ const sessionContext = this.buildDisplaySessionContext();
14223
14560
 
14224
- await this.#restoreMCPSelectionsForSessionContext(sessionContext);
14561
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext);
14225
14562
 
14226
- if (!skipConversationRestore) {
14227
- this.agent.replaceMessages(sessionContext.messages);
14228
- this.#resetInjectedContextSignatures();
14229
- this.#closeCodexProviderSessionsForHistoryRewrite();
14230
- }
14563
+ if (!skipConversationRestore) {
14564
+ this.agent.replaceMessages(sessionContext.messages);
14565
+ this.#resetInjectedContextSignatures();
14566
+ this.#closeCodexProviderSessionsForHistoryRewrite();
14567
+ }
14231
14568
 
14232
- this.#resetIrcRosterDeliveryState();
14233
- // session_branch is the post-commit identity signal. Publish it only after
14234
- // the successor's messages and MCP selections are restored.
14235
- if (this.#extensionRunner) {
14236
- await this.#extensionRunner.emit({
14237
- type: "session_branch",
14238
- previousSessionFile,
14239
- });
14240
- }
14569
+ this.#resetIrcRosterDeliveryState();
14570
+ // session_branch is the post-commit identity signal. Publish it only after
14571
+ // the successor's messages and MCP selections are restored.
14572
+ if (this.#extensionRunner) {
14573
+ await this.#extensionRunner.emit({
14574
+ type: "session_branch",
14575
+ previousSessionFile,
14576
+ });
14577
+ }
14241
14578
 
14242
- return { selectedText, cancelled: false };
14579
+ return { selectedText, cancelled: false };
14580
+ } finally {
14581
+ this.#endSessionTransition();
14582
+ }
14243
14583
  }
14244
14584
 
14245
14585
  // =========================================================================
@@ -14266,165 +14606,178 @@ export class AgentSession {
14266
14606
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
14267
14607
  sessionContext?: SessionContext;
14268
14608
  }> {
14269
- const oldLeafId = this.sessionManager.getLeafId();
14609
+ // Serialize with every other session-identity transition via the shared
14610
+ // lease (handoff/compact/new/switch/branch/clear/fork). navigateTree rewrites
14611
+ // live history in place, so a concurrent transition would race the same state.
14612
+ this.#beginSessionTransition("navigate-tree");
14613
+ try {
14614
+ const oldLeafId = this.sessionManager.getLeafId();
14270
14615
 
14271
- // No-op if already at target
14272
- if (targetId === oldLeafId) {
14273
- return { cancelled: false };
14274
- }
14616
+ // No-op if already at target
14617
+ if (targetId === oldLeafId) {
14618
+ return { cancelled: false };
14619
+ }
14275
14620
 
14276
- // Model required for summarization
14277
- if (options.summarize && !this.model) {
14278
- throw new Error("No model available for summarization");
14279
- }
14621
+ // Model required for summarization
14622
+ if (options.summarize && !this.model) {
14623
+ throw new Error("No model available for summarization");
14624
+ }
14280
14625
 
14281
- const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
14282
- if (!targetEntry) {
14283
- throw new Error(`Entry ${targetId} not found`);
14284
- }
14626
+ const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
14627
+ if (!targetEntry) {
14628
+ throw new Error(`Entry ${targetId} not found`);
14629
+ }
14285
14630
 
14286
- // Collect entries to summarize (from old leaf to common ancestor).
14287
- const { entries: collectedEntriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
14288
- this.sessionManager,
14289
- oldLeafId,
14290
- targetId,
14291
- );
14292
- const entriesToSummarize = this.#withoutEphemeralCustomMessageEntries(collectedEntriesToSummarize);
14293
-
14294
- // Prepare event data
14295
- const preparation: TreePreparation = {
14296
- targetId,
14297
- oldLeafId,
14298
- commonAncestorId,
14299
- entriesToSummarize,
14300
- userWantsSummary: options.summarize ?? false,
14301
- };
14631
+ // Collect entries to summarize (from old leaf to common ancestor).
14632
+ const { entries: collectedEntriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
14633
+ this.sessionManager,
14634
+ oldLeafId,
14635
+ targetId,
14636
+ );
14637
+ const entriesToSummarize = this.#withoutEphemeralCustomMessageEntries(collectedEntriesToSummarize);
14302
14638
 
14303
- // Set up abort controller for summarization
14304
- this.#branchSummaryAbortController = new AbortController();
14305
- let hookSummary: { summary: string; details?: unknown } | undefined;
14306
- let fromExtension = false;
14639
+ // Prepare event data
14640
+ const preparation: TreePreparation = {
14641
+ targetId,
14642
+ oldLeafId,
14643
+ commonAncestorId,
14644
+ entriesToSummarize,
14645
+ userWantsSummary: options.summarize ?? false,
14646
+ };
14307
14647
 
14308
- // Emit session_before_tree event
14309
- if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
14310
- const result = (await this.#extensionRunner.emit({
14311
- type: "session_before_tree",
14312
- preparation,
14313
- signal: this.#branchSummaryAbortController.signal,
14314
- })) as SessionBeforeTreeResult | undefined;
14648
+ // Set up abort controller for summarization
14649
+ this.#branchSummaryAbortController = new AbortController();
14650
+ let hookSummary: { summary: string; details?: unknown } | undefined;
14651
+ let fromExtension = false;
14315
14652
 
14316
- if (result?.cancel) {
14317
- return { cancelled: true };
14318
- }
14653
+ // Emit session_before_tree event
14654
+ if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
14655
+ const result = (await this.#extensionRunner.emit({
14656
+ type: "session_before_tree",
14657
+ preparation,
14658
+ signal: this.#branchSummaryAbortController.signal,
14659
+ })) as SessionBeforeTreeResult | undefined;
14319
14660
 
14320
- if (result?.summary && options.summarize) {
14321
- hookSummary = result.summary;
14322
- fromExtension = true;
14323
- }
14324
- }
14661
+ if (result?.cancel) {
14662
+ return { cancelled: true };
14663
+ }
14325
14664
 
14326
- // Run default summarizer if needed
14327
- let summaryText: string | undefined;
14328
- let summaryDetails: unknown;
14329
- if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
14330
- const model = this.model!;
14331
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
14332
- if (!apiKey) {
14333
- throw new Error(`No API key for ${model.provider}`);
14334
- }
14335
- const branchSummarySettings = this.settings.getGroup("branchSummary");
14336
- const result = await generateBranchSummary(entriesToSummarize, {
14337
- ...this.#maintenanceProviderTransport(),
14338
- model,
14339
- apiKey,
14340
- signal: this.#branchSummaryAbortController.signal,
14341
- customInstructions: options.customInstructions,
14342
- reserveTokens: branchSummarySettings.reserveTokens,
14343
- metadata: this.agent.metadataForProvider(model.provider),
14344
- convertToLlm,
14345
- telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
14346
- });
14347
- this.#branchSummaryAbortController = undefined;
14348
- if (result.aborted) {
14349
- return { cancelled: true, aborted: true };
14665
+ if (result?.summary && options.summarize) {
14666
+ hookSummary = result.summary;
14667
+ fromExtension = true;
14668
+ }
14350
14669
  }
14351
- if (result.error) {
14352
- throw new Error(result.error);
14670
+
14671
+ // Run default summarizer if needed
14672
+ let summaryText: string | undefined;
14673
+ let summaryDetails: unknown;
14674
+ if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
14675
+ const model = this.model!;
14676
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
14677
+ if (!apiKey) {
14678
+ throw new Error(`No API key for ${model.provider}`);
14679
+ }
14680
+ const branchSummarySettings = this.settings.getGroup("branchSummary");
14681
+ const result = await generateBranchSummary(entriesToSummarize, {
14682
+ ...this.#maintenanceProviderTransport(),
14683
+ model,
14684
+ apiKey,
14685
+ signal: this.#branchSummaryAbortController.signal,
14686
+ customInstructions: options.customInstructions,
14687
+ reserveTokens: branchSummarySettings.reserveTokens,
14688
+ metadata: this.agent.metadataForProvider(model.provider),
14689
+ convertToLlm,
14690
+ telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
14691
+ });
14692
+ this.#branchSummaryAbortController = undefined;
14693
+ if (result.aborted) {
14694
+ return { cancelled: true, aborted: true };
14695
+ }
14696
+ if (result.error) {
14697
+ throw new Error(result.error);
14698
+ }
14699
+ summaryText = result.summary;
14700
+ summaryDetails = {
14701
+ readFiles: result.readFiles || [],
14702
+ modifiedFiles: result.modifiedFiles || [],
14703
+ };
14704
+ } else if (hookSummary) {
14705
+ summaryText = hookSummary.summary;
14706
+ summaryDetails = hookSummary.details;
14707
+ }
14708
+
14709
+ // Determine the new leaf position based on target type
14710
+ let newLeafId: string | null;
14711
+ let editorText: string | undefined;
14712
+
14713
+ if (targetEntry.type === "message" && targetEntry.message.role === "user") {
14714
+ // User message: leaf = parent (null if root), text goes to editor
14715
+ newLeafId = targetEntry.parentId;
14716
+ editorText = this.#extractUserMessageText(targetEntry.message.content);
14717
+ } else if (targetEntry.type === "custom_message") {
14718
+ // Custom message: leaf = parent (null if root), text goes to editor
14719
+ newLeafId = targetEntry.parentId;
14720
+ editorText =
14721
+ typeof targetEntry.content === "string"
14722
+ ? targetEntry.content
14723
+ : targetEntry.content
14724
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
14725
+ .map(c => c.text)
14726
+ .join("");
14727
+ } else {
14728
+ // Non-user message: leaf = selected node
14729
+ newLeafId = targetId;
14730
+ }
14731
+
14732
+ // Switch leaf (with or without summary)
14733
+ // Summary is attached at the navigation target position (newLeafId), not the old branch
14734
+ let summaryEntry: BranchSummaryEntry | undefined;
14735
+ if (summaryText) {
14736
+ // Create summary at target position (can be null for root)
14737
+ const summaryId = this.sessionManager.branchWithSummary(
14738
+ newLeafId,
14739
+ summaryText,
14740
+ summaryDetails,
14741
+ fromExtension,
14742
+ );
14743
+ summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
14744
+ } else if (newLeafId === null) {
14745
+ // No summary, navigating to root - reset leaf
14746
+ this.sessionManager.resetLeaf();
14747
+ } else {
14748
+ // No summary, navigating to non-root
14749
+ this.sessionManager.branch(newLeafId);
14353
14750
  }
14354
- summaryText = result.summary;
14355
- summaryDetails = {
14356
- readFiles: result.readFiles || [],
14357
- modifiedFiles: result.modifiedFiles || [],
14358
- };
14359
- } else if (hookSummary) {
14360
- summaryText = hookSummary.summary;
14361
- summaryDetails = hookSummary.details;
14362
- }
14363
-
14364
- // Determine the new leaf position based on target type
14365
- let newLeafId: string | null;
14366
- let editorText: string | undefined;
14367
-
14368
- if (targetEntry.type === "message" && targetEntry.message.role === "user") {
14369
- // User message: leaf = parent (null if root), text goes to editor
14370
- newLeafId = targetEntry.parentId;
14371
- editorText = this.#extractUserMessageText(targetEntry.message.content);
14372
- } else if (targetEntry.type === "custom_message") {
14373
- // Custom message: leaf = parent (null if root), text goes to editor
14374
- newLeafId = targetEntry.parentId;
14375
- editorText =
14376
- typeof targetEntry.content === "string"
14377
- ? targetEntry.content
14378
- : targetEntry.content
14379
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
14380
- .map(c => c.text)
14381
- .join("");
14382
- } else {
14383
- // Non-user message: leaf = selected node
14384
- newLeafId = targetId;
14385
- }
14386
-
14387
- // Switch leaf (with or without summary)
14388
- // Summary is attached at the navigation target position (newLeafId), not the old branch
14389
- let summaryEntry: BranchSummaryEntry | undefined;
14390
- if (summaryText) {
14391
- // Create summary at target position (can be null for root)
14392
- const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
14393
- summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
14394
- } else if (newLeafId === null) {
14395
- // No summary, navigating to root - reset leaf
14396
- this.sessionManager.resetLeaf();
14397
- } else {
14398
- // No summary, navigating to non-root
14399
- this.sessionManager.branch(newLeafId);
14400
- }
14401
14751
 
14402
- // Update agent state through the canonical filtered display context so legacy
14403
- // request-scoped entries cannot re-enter live history after tree navigation.
14404
- const displayContext = this.buildDisplaySessionContext();
14405
- await this.#restoreMCPSelectionsForSessionContext(displayContext);
14406
- this.agent.replaceMessages(displayContext.messages);
14407
- this.#resetInjectedContextSignatures();
14408
- this.#syncTodoPhasesFromBranch();
14409
- this.#closeCodexProviderSessionsForHistoryRewrite();
14752
+ // Update agent state through the canonical filtered display context so legacy
14753
+ // request-scoped entries cannot re-enter live history after tree navigation.
14754
+ const displayContext = this.buildDisplaySessionContext();
14755
+ await this.#restoreMCPSelectionsForSessionContext(displayContext);
14756
+ this.agent.replaceMessages(displayContext.messages);
14757
+ this.#resetInjectedContextSignatures();
14758
+ this.#syncTodoPhasesFromBranch();
14759
+ this.#closeCodexProviderSessionsForHistoryRewrite();
14410
14760
 
14411
- this.#branchSummaryAbortController = undefined;
14761
+ this.#branchSummaryAbortController = undefined;
14412
14762
 
14413
- // Emit session_tree event; only handlers can mutate session entries, so skip
14414
- // the emit and the context rebuild when no handlers are registered (mirrors
14415
- // the session_before_tree guard above).
14416
- if (this.#extensionRunner?.hasHandlers("session_tree")) {
14417
- await this.#extensionRunner.emit({
14418
- type: "session_tree",
14419
- newLeafId: this.sessionManager.getLeafId(),
14420
- oldLeafId,
14421
- summaryEntry,
14422
- fromExtension: summaryText ? fromExtension : undefined,
14423
- });
14424
- const refreshedContext = this.buildDisplaySessionContext();
14425
- return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext };
14763
+ // Emit session_tree event; only handlers can mutate session entries, so skip
14764
+ // the emit and the context rebuild when no handlers are registered (mirrors
14765
+ // the session_before_tree guard above).
14766
+ if (this.#extensionRunner?.hasHandlers("session_tree")) {
14767
+ await this.#extensionRunner.emit({
14768
+ type: "session_tree",
14769
+ newLeafId: this.sessionManager.getLeafId(),
14770
+ oldLeafId,
14771
+ summaryEntry,
14772
+ fromExtension: summaryText ? fromExtension : undefined,
14773
+ });
14774
+ const refreshedContext = this.buildDisplaySessionContext();
14775
+ return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext };
14776
+ }
14777
+ return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
14778
+ } finally {
14779
+ this.#endSessionTransition();
14426
14780
  }
14427
- return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
14428
14781
  }
14429
14782
 
14430
14783
  /**