@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +423 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/export/html/template.css +43 -13
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.html +1 -0
  27. package/src/export/html/template.js +107 -0
  28. package/src/extensibility/extensions/types.ts +31 -8
  29. package/src/internal-urls/docs-index.generated.ts +1 -1
  30. package/src/lsp/index.ts +1 -1
  31. package/src/main.ts +44 -44
  32. package/src/mcp/oauth-discovery.ts +1 -1
  33. package/src/modes/acp/acp-agent.ts +957 -0
  34. package/src/modes/acp/acp-event-mapper.ts +531 -0
  35. package/src/modes/acp/acp-mode.ts +13 -0
  36. package/src/modes/acp/index.ts +2 -0
  37. package/src/modes/components/agent-dashboard.ts +5 -4
  38. package/src/modes/components/custom-editor.ts +53 -51
  39. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  40. package/src/modes/components/history-search.ts +2 -1
  41. package/src/modes/components/hook-editor.ts +2 -1
  42. package/src/modes/components/hook-input.ts +8 -7
  43. package/src/modes/components/hook-selector.ts +15 -10
  44. package/src/modes/components/keybinding-hints.ts +9 -9
  45. package/src/modes/components/login-dialog.ts +3 -3
  46. package/src/modes/components/mcp-add-wizard.ts +2 -1
  47. package/src/modes/components/model-selector.ts +14 -3
  48. package/src/modes/components/oauth-selector.ts +2 -1
  49. package/src/modes/components/session-selector.ts +2 -1
  50. package/src/modes/components/settings-selector.ts +2 -1
  51. package/src/modes/components/status-line-segment-editor.ts +2 -1
  52. package/src/modes/components/tree-selector.ts +3 -2
  53. package/src/modes/components/user-message-selector.ts +3 -8
  54. package/src/modes/components/user-message.ts +16 -0
  55. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  56. package/src/modes/controllers/input-controller.ts +48 -29
  57. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  58. package/src/modes/index.ts +1 -0
  59. package/src/modes/interactive-mode.ts +17 -5
  60. package/src/modes/print-mode.ts +1 -1
  61. package/src/modes/prompt-action-autocomplete.ts +7 -7
  62. package/src/modes/rpc/rpc-mode.ts +7 -2
  63. package/src/modes/rpc/rpc-types.ts +1 -0
  64. package/src/modes/theme/theme.ts +53 -44
  65. package/src/modes/types.ts +9 -2
  66. package/src/modes/utils/hotkeys-markdown.ts +20 -20
  67. package/src/modes/utils/keybinding-matchers.ts +21 -0
  68. package/src/modes/utils/ui-helpers.ts +1 -1
  69. package/src/patch/hashline.ts +139 -127
  70. package/src/patch/index.ts +77 -59
  71. package/src/patch/shared.ts +19 -11
  72. package/src/prompts/tools/hashline.md +43 -116
  73. package/src/sdk.ts +34 -17
  74. package/src/session/agent-session.ts +436 -86
  75. package/src/session/messages.ts +23 -0
  76. package/src/session/session-manager.ts +97 -31
  77. package/src/tools/ask.ts +56 -30
  78. package/src/tools/bash-interceptor.ts +1 -39
  79. package/src/tools/bash-skill-urls.ts +1 -1
  80. package/src/tools/browser.ts +1 -1
  81. package/src/tools/gemini-image.ts +1 -1
  82. package/src/tools/resolve.ts +1 -1
  83. package/src/utils/child-process.ts +88 -0
  84. package/src/utils/image-input.ts +11 -1
  85. package/src/web/search/providers/codex.ts +10 -3
@@ -46,6 +46,7 @@ import {
46
46
  calculateRateLimitBackoffMs,
47
47
  getSupportedEfforts,
48
48
  isContextOverflow,
49
+ isUsageLimitError,
49
50
  modelsAreEqual,
50
51
  parseRateLimitReason,
51
52
  } from "@oh-my-pi/pi-ai";
@@ -224,6 +225,8 @@ export interface AgentSessionConfig {
224
225
  mcpDiscoveryEnabled?: boolean;
225
226
  /** MCP tool names to activate for the current session when discovery mode is enabled. */
226
227
  initialSelectedMCPToolNames?: string[];
228
+ /** Whether constructor-provided MCP defaults should be persisted immediately. */
229
+ persistInitialMCPToolSelection?: boolean;
227
230
  /** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
228
231
  defaultSelectedMCPServerNames?: string[];
229
232
  /** MCP tool names that should seed brand-new sessions created from this AgentSession. */
@@ -363,6 +366,7 @@ export class AgentSession {
363
366
  #followUpMessages: string[] = [];
364
367
  /** Messages queued to be included with the next user prompt as context ("asides"). */
365
368
  #pendingNextTurnMessages: CustomMessage[] = [];
369
+ #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
366
370
  #planModeState: PlanModeState | undefined;
367
371
  #planReferenceSent = false;
368
372
  #planReferencePath = "local://PLAN.md";
@@ -483,8 +487,11 @@ export class AgentSession {
483
487
  this.#pruneSelectedMCPToolNames();
484
488
  const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
485
489
  const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
490
+ const persistInitialMCPToolSelection =
491
+ config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
486
492
  if (
487
493
  this.#mcpDiscoveryEnabled &&
494
+ persistInitialMCPToolSelection &&
488
495
  !this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
489
496
  ) {
490
497
  this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
@@ -781,7 +788,6 @@ export class AgentSession {
781
788
  attempt: this.#retryAttempt,
782
789
  });
783
790
  this.#retryAttempt = 0;
784
- this.#resolveRetry();
785
791
  }
786
792
  }
787
793
 
@@ -857,6 +863,7 @@ export class AgentSession {
857
863
  const didRetry = await this.#handleRetryableError(msg);
858
864
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
859
865
  }
866
+ this.#resolveRetry();
860
867
 
861
868
  if (msg.stopReason === "aborted" && this.#checkpointState) {
862
869
  this.#checkpointState = undefined;
@@ -1593,16 +1600,29 @@ export class AgentSession {
1593
1600
  logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
1594
1601
  }
1595
1602
  await this.sessionManager.close();
1596
- for (const state of this.#providerSessionState.values()) {
1597
- state.close();
1598
- }
1599
- this.#providerSessionState.clear();
1603
+ this.#closeAllProviderSessions("dispose");
1600
1604
  this.#unsubscribePendingActionPush?.();
1601
1605
  this.#unsubscribePendingActionPush = undefined;
1602
1606
  this.#disconnectFromAgent();
1603
1607
  this.#eventListeners = [];
1604
1608
  }
1605
1609
 
1610
+ #closeAllProviderSessions(reason: string): void {
1611
+ for (const [providerKey, state] of this.#providerSessionState) {
1612
+ try {
1613
+ state.close();
1614
+ } catch (error) {
1615
+ logger.warn("Failed to close provider session state", {
1616
+ providerKey,
1617
+ reason,
1618
+ error: String(error),
1619
+ });
1620
+ }
1621
+ }
1622
+
1623
+ this.#providerSessionState.clear();
1624
+ }
1625
+
1606
1626
  // =========================================================================
1607
1627
  // Read-only State Access
1608
1628
  // =========================================================================
@@ -2566,6 +2586,74 @@ export class AgentSession {
2566
2586
  });
2567
2587
  }
2568
2588
 
2589
+ #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
2590
+ this.#pendingNextTurnMessages.push(message);
2591
+ if (!triggerTurn) return;
2592
+ const generation = this.#promptGeneration;
2593
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2594
+ return;
2595
+ }
2596
+ this.#scheduledHiddenNextTurnGeneration = generation;
2597
+ this.#schedulePostPromptTask(
2598
+ async () => {
2599
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2600
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2601
+ }
2602
+ if (this.#pendingNextTurnMessages.length === 0) {
2603
+ return;
2604
+ }
2605
+ try {
2606
+ await this.#promptQueuedHiddenNextTurnMessages();
2607
+ } catch {
2608
+ // Leave the hidden next-turn messages queued for the next explicit prompt.
2609
+ }
2610
+ },
2611
+ {
2612
+ generation,
2613
+ onSkip: () => {
2614
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2615
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2616
+ }
2617
+ },
2618
+ },
2619
+ );
2620
+ }
2621
+
2622
+ async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
2623
+ if (this.#pendingNextTurnMessages.length === 0) {
2624
+ return;
2625
+ }
2626
+
2627
+ const queuedMessages = [...this.#pendingNextTurnMessages];
2628
+ this.#pendingNextTurnMessages = [];
2629
+ const message = queuedMessages[queuedMessages.length - 1];
2630
+ if (!message) {
2631
+ return;
2632
+ }
2633
+
2634
+ const prependMessages = queuedMessages.slice(0, -1);
2635
+ const textContent = this.#getCustomMessageTextContent(message);
2636
+ try {
2637
+ await this.#promptWithMessage(message, textContent, {
2638
+ prependMessages,
2639
+ skipPostPromptRecoveryWait: true,
2640
+ });
2641
+ } catch (error) {
2642
+ this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
2643
+ throw error;
2644
+ }
2645
+ }
2646
+
2647
+ #getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
2648
+ if (typeof message.content === "string") {
2649
+ return message.content;
2650
+ }
2651
+ return message.content
2652
+ .filter((content): content is TextContent => content.type === "text")
2653
+ .map(content => content.text)
2654
+ .join("");
2655
+ }
2656
+
2569
2657
  /**
2570
2658
  * Throw an error if the text is an extension command.
2571
2659
  */
@@ -2606,7 +2694,7 @@ export class AgentSession {
2606
2694
  };
2607
2695
  if (this.isStreaming) {
2608
2696
  if (options?.deliverAs === "nextTurn") {
2609
- this.#pendingNextTurnMessages.push(appMessage);
2697
+ this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
2610
2698
  return;
2611
2699
  }
2612
2700
 
@@ -2618,6 +2706,22 @@ export class AgentSession {
2618
2706
  return;
2619
2707
  }
2620
2708
 
2709
+ if (options?.deliverAs === "nextTurn") {
2710
+ if (options?.triggerTurn) {
2711
+ await this.agent.prompt(appMessage);
2712
+ return;
2713
+ }
2714
+ this.agent.appendMessage(appMessage);
2715
+ this.sessionManager.appendCustomMessageEntry(
2716
+ message.customType,
2717
+ message.content,
2718
+ message.display,
2719
+ message.details,
2720
+ message.attribution ?? "agent",
2721
+ );
2722
+ return;
2723
+ }
2724
+
2621
2725
  if (options?.triggerTurn) {
2622
2726
  await this.agent.prompt(appMessage);
2623
2727
  return;
@@ -2685,9 +2789,9 @@ export class AgentSession {
2685
2789
  return { steering, followUp };
2686
2790
  }
2687
2791
 
2688
- /** Number of pending messages (includes both steering and follow-up) */
2792
+ /** Number of pending messages (includes steering, follow-up, and next-turn messages) */
2689
2793
  get queuedMessageCount(): number {
2690
- return this.#steeringMessages.length + this.#followUpMessages.length;
2794
+ return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
2691
2795
  }
2692
2796
 
2693
2797
  /** Get pending messages (read-only) */
@@ -2829,6 +2933,7 @@ export class AgentSession {
2829
2933
  async abort(): Promise<void> {
2830
2934
  this.abortRetry();
2831
2935
  this.#promptGeneration++;
2936
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2832
2937
  this.#resolveTtsrResume();
2833
2938
  this.#cancelPostPromptTasks();
2834
2939
  this.agent.abort();
@@ -2870,6 +2975,7 @@ export class AgentSession {
2870
2975
  this.#disconnectFromAgent();
2871
2976
  await this.abort();
2872
2977
  this.#asyncJobManager?.cancelAll();
2978
+ this.#closeAllProviderSessions("new session");
2873
2979
  this.agent.reset();
2874
2980
  await this.sessionManager.flush();
2875
2981
  await this.sessionManager.newSession(options);
@@ -2878,6 +2984,7 @@ export class AgentSession {
2878
2984
  this.#steeringMessages = [];
2879
2985
  this.#followUpMessages = [];
2880
2986
  this.#pendingNextTurnMessages = [];
2987
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2881
2988
 
2882
2989
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
2883
2990
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
@@ -3611,6 +3718,7 @@ export class AgentSession {
3611
3718
  this.#steeringMessages = [];
3612
3719
  this.#followUpMessages = [];
3613
3720
  this.#pendingNextTurnMessages = [];
3721
+ this.#scheduledHiddenNextTurnGeneration = undefined;
3614
3722
  this.#todoReminderCount = 0;
3615
3723
 
3616
3724
  // Inject the handoff document as a custom message
@@ -3991,22 +4099,174 @@ export class AgentSession {
3991
4099
  }
3992
4100
 
3993
4101
  #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
3994
- if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
4102
+ const providerKeys = new Set<string>();
4103
+ if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
4104
+ providerKeys.add("openai-codex-responses");
4105
+ }
4106
+ if (currentModel.api === "openai-responses") {
4107
+ providerKeys.add(`openai-responses:${currentModel.provider}`);
4108
+ }
4109
+ if (nextModel.api === "openai-responses") {
4110
+ providerKeys.add(`openai-responses:${nextModel.provider}`);
4111
+ }
3995
4112
 
3996
- const providerKey = "openai-codex-responses";
3997
- const state = this.#providerSessionState.get(providerKey);
3998
- if (!state) return;
4113
+ for (const providerKey of providerKeys) {
4114
+ const state = this.#providerSessionState.get(providerKey);
4115
+ if (!state) continue;
3999
4116
 
4000
- try {
4001
- state.close();
4002
- } catch (error) {
4003
- logger.warn("Failed to close provider session state during model switch", {
4004
- providerKey,
4005
- error: String(error),
4006
- });
4117
+ try {
4118
+ state.close();
4119
+ } catch (error) {
4120
+ logger.warn("Failed to close provider session state during model switch", {
4121
+ providerKey,
4122
+ error: String(error),
4123
+ });
4124
+ }
4125
+
4126
+ this.#providerSessionState.delete(providerKey);
4127
+ }
4128
+ }
4129
+
4130
+ #normalizeProviderReplayValue(value: unknown): unknown {
4131
+ if (Array.isArray(value)) {
4132
+ return value.map(item => this.#normalizeProviderReplayValue(item));
4133
+ }
4134
+ if (value && typeof value === "object") {
4135
+ return Object.fromEntries(
4136
+ Object.entries(value).map(([key, entryValue]) => [key, this.#normalizeProviderReplayValue(entryValue)]),
4137
+ );
4007
4138
  }
4139
+ return value;
4140
+ }
4008
4141
 
4009
- this.#providerSessionState.delete(providerKey);
4142
+ #normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
4143
+ switch (message.role) {
4144
+ case "user":
4145
+ case "developer":
4146
+ return {
4147
+ role: message.role,
4148
+ content: this.#normalizeProviderReplayValue(message.content),
4149
+ providerPayload: message.providerPayload,
4150
+ };
4151
+ case "assistant": {
4152
+ const isResponsesFamilyMessage =
4153
+ message.api === "openai-responses" || message.api === "openai-codex-responses";
4154
+ return {
4155
+ role: message.role,
4156
+ content:
4157
+ isResponsesFamilyMessage && Array.isArray(message.content)
4158
+ ? message.content.flatMap(block => {
4159
+ if (block.type === "thinking") {
4160
+ return [];
4161
+ }
4162
+ if (block.type === "toolCall") {
4163
+ return [
4164
+ {
4165
+ type: block.type,
4166
+ id: block.id,
4167
+ name: block.name,
4168
+ arguments: block.arguments,
4169
+ },
4170
+ ];
4171
+ }
4172
+ if (block.type === "text") {
4173
+ return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
4174
+ }
4175
+ return [this.#normalizeProviderReplayValue(block)];
4176
+ })
4177
+ : this.#normalizeProviderReplayValue(message.content),
4178
+ api: message.api,
4179
+ provider: message.provider,
4180
+ model: message.model,
4181
+ stopReason: message.stopReason,
4182
+ errorMessage: message.errorMessage,
4183
+ providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
4184
+ };
4185
+ }
4186
+ case "toolResult":
4187
+ return {
4188
+ role: message.role,
4189
+ toolName: message.toolName,
4190
+ toolCallId: message.toolCallId,
4191
+ isError: message.isError,
4192
+ content: this.#normalizeProviderReplayValue(message.content),
4193
+ };
4194
+ case "bashExecution":
4195
+ return {
4196
+ role: message.role,
4197
+ command: message.command,
4198
+ output: message.output,
4199
+ exitCode: message.exitCode,
4200
+ cancelled: message.cancelled,
4201
+ meta: message.meta
4202
+ ? {
4203
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4204
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4205
+ diagnostics: message.meta.diagnostics
4206
+ ? this.#normalizeProviderReplayValue({
4207
+ summary: message.meta.diagnostics.summary,
4208
+ messages: message.meta.diagnostics.messages,
4209
+ })
4210
+ : undefined,
4211
+ }
4212
+ : undefined,
4213
+ excludeFromContext: message.excludeFromContext,
4214
+ };
4215
+ case "pythonExecution":
4216
+ return {
4217
+ role: message.role,
4218
+ code: message.code,
4219
+ output: message.output,
4220
+ exitCode: message.exitCode,
4221
+ cancelled: message.cancelled,
4222
+ meta: message.meta
4223
+ ? {
4224
+ truncation: this.#normalizeProviderReplayValue(message.meta.truncation),
4225
+ limits: this.#normalizeProviderReplayValue(message.meta.limits),
4226
+ diagnostics: message.meta.diagnostics
4227
+ ? this.#normalizeProviderReplayValue({
4228
+ summary: message.meta.diagnostics.summary,
4229
+ messages: message.meta.diagnostics.messages,
4230
+ })
4231
+ : undefined,
4232
+ }
4233
+ : undefined,
4234
+ excludeFromContext: message.excludeFromContext,
4235
+ };
4236
+ case "custom":
4237
+ case "hookMessage":
4238
+ return {
4239
+ role: message.role,
4240
+ customType: message.customType,
4241
+ content: this.#normalizeProviderReplayValue(message.content),
4242
+ };
4243
+ case "branchSummary":
4244
+ return { role: message.role, summary: message.summary };
4245
+ case "compactionSummary":
4246
+ return {
4247
+ role: message.role,
4248
+ summary: message.summary,
4249
+ providerPayload: message.providerPayload,
4250
+ };
4251
+ case "fileMention":
4252
+ return {
4253
+ role: message.role,
4254
+ files: message.files.map(file => ({
4255
+ path: file.path,
4256
+ content: file.content,
4257
+ image: file.image,
4258
+ })),
4259
+ };
4260
+ default:
4261
+ return this.#normalizeProviderReplayValue(message);
4262
+ }
4263
+ }
4264
+
4265
+ #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
4266
+ return (
4267
+ JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
4268
+ JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
4269
+ );
4010
4270
  }
4011
4271
 
4012
4272
  #getModelKey(model: Model): string {
@@ -4278,7 +4538,9 @@ export class AgentSession {
4278
4538
  const shouldRetry =
4279
4539
  retrySettings.enabled &&
4280
4540
  attempt < retrySettings.maxRetries &&
4281
- (retryAfterMs !== undefined || this.#isRetryableErrorMessage(message));
4541
+ (retryAfterMs !== undefined ||
4542
+ this.#isTransientErrorMessage(message) ||
4543
+ isUsageLimitError(message));
4282
4544
  if (!shouldRetry) {
4283
4545
  lastError = error;
4284
4546
  break;
@@ -4476,8 +4738,9 @@ export class AgentSession {
4476
4738
  // =========================================================================
4477
4739
 
4478
4740
  /**
4479
- * Check if an error is retryable (overloaded, rate limit, server errors).
4741
+ * Check if an error is retryable (transient errors or usage limits).
4480
4742
  * Context overflow errors are NOT retryable (handled by compaction instead).
4743
+ * Usage-limit errors are retryable because the retry handler performs credential switching.
4481
4744
  */
4482
4745
  #isRetryableError(message: AssistantMessage): boolean {
4483
4746
  if (message.stopReason !== "error" || !message.errorMessage) return false;
@@ -4487,20 +4750,17 @@ export class AgentSession {
4487
4750
  if (isContextOverflow(message, contextWindow)) return false;
4488
4751
 
4489
4752
  const err = message.errorMessage;
4490
- return this.#isRetryableErrorMessage(err);
4753
+ return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
4491
4754
  }
4492
4755
 
4493
- #isRetryableErrorMessage(errorMessage: string): boolean {
4494
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded, stream stall
4495
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
4756
+ #isTransientErrorMessage(errorMessage: string): boolean {
4757
+ // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
4758
+ // service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
4759
+ return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
4496
4760
  errorMessage,
4497
4761
  );
4498
4762
  }
4499
4763
 
4500
- #isUsageLimitErrorMessage(errorMessage: string): boolean {
4501
- return /usage.?limit|usage_limit_reached|limit_reached|quota.?exceeded|resource.?exhausted/i.test(errorMessage);
4502
- }
4503
-
4504
4764
  #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
4505
4765
  const now = Date.now();
4506
4766
  const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
@@ -4582,7 +4842,7 @@ export class AgentSession {
4582
4842
  const errorMessage = message.errorMessage || "Unknown error";
4583
4843
  let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
4584
4844
 
4585
- if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
4845
+ if (this.model && isUsageLimitError(errorMessage)) {
4586
4846
  const retryAfterMs =
4587
4847
  this.#parseRetryAfterMsFromError(errorMessage) ??
4588
4848
  calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
@@ -4941,7 +5201,9 @@ export class AgentSession {
4941
5201
  */
4942
5202
  async switchSession(sessionPath: string): Promise<boolean> {
4943
5203
  const previousSessionFile = this.sessionManager.getSessionFile();
4944
-
5204
+ const switchingToDifferentSession = previousSessionFile
5205
+ ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
5206
+ : true;
4945
5207
  // Emit session_before_switch event (can be cancelled)
4946
5208
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
4947
5209
  const result = (await this.#extensionRunner.emit({
@@ -4957,70 +5219,149 @@ export class AgentSession {
4957
5219
 
4958
5220
  this.#disconnectFromAgent();
4959
5221
  await this.abort();
4960
- this.#steeringMessages = [];
4961
- this.#followUpMessages = [];
4962
- this.#pendingNextTurnMessages = [];
4963
5222
 
4964
- // Flush pending writes before switching
5223
+ // Flush pending writes before switching so restore snapshots reflect committed state.
4965
5224
  await this.sessionManager.flush();
5225
+ const previousSessionState = this.sessionManager.captureState();
5226
+ const previousSessionContext = this.sessionManager.buildSessionContext();
5227
+ // switchSession replaces these arrays wholesale during load/rollback, so retaining
5228
+ // the existing message objects is sufficient and avoids structured-clone failures for
5229
+ // extension/custom metadata that is valid to persist but not cloneable.
5230
+ const previousAgentMessages = [...this.agent.state.messages];
5231
+ const previousSteeringMessages = [...this.#steeringMessages];
5232
+ const previousFollowUpMessages = [...this.#followUpMessages];
5233
+ const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
5234
+ const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
5235
+ const previousModel = this.model;
5236
+ const previousThinkingLevel = this.#thinkingLevel;
5237
+ const previousServiceTier = this.agent.serviceTier;
5238
+ const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
5239
+ const previousTools = [...this.agent.state.tools];
5240
+ const previousBaseSystemPrompt = this.#baseSystemPrompt;
5241
+ const previousSystemPrompt = this.agent.state.systemPrompt;
5242
+ const previousFallbackSelectedMCPToolNames = previousSessionFile
5243
+ ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
5244
+ : undefined;
4966
5245
 
4967
- // Set new session
4968
- await this.sessionManager.setSessionFile(sessionPath);
4969
- this.agent.sessionId = this.sessionManager.getSessionId();
5246
+ this.#steeringMessages = [];
5247
+ this.#followUpMessages = [];
5248
+ this.#pendingNextTurnMessages = [];
5249
+ this.#scheduledHiddenNextTurnGeneration = undefined;
4970
5250
 
4971
- // Reload messages
4972
- const sessionContext = this.sessionManager.buildSessionContext();
4973
- const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
4974
- await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
5251
+ try {
5252
+ await this.sessionManager.setSessionFile(sessionPath);
5253
+ this.agent.sessionId = this.sessionManager.getSessionId();
4975
5254
 
4976
- // Emit session_switch event to hooks
4977
- if (this.#extensionRunner) {
4978
- await this.#extensionRunner.emit({
4979
- type: "session_switch",
4980
- reason: "resume",
4981
- previousSessionFile,
4982
- });
4983
- }
5255
+ const sessionContext = this.sessionManager.buildSessionContext();
5256
+ const didReloadConversationChange =
5257
+ !switchingToDifferentSession &&
5258
+ this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
5259
+ const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
5260
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
4984
5261
 
4985
- this.agent.replaceMessages(sessionContext.messages);
4986
- this.#syncTodoPhasesFromBranch();
5262
+ // Emit session_switch event to hooks
5263
+ if (this.#extensionRunner) {
5264
+ await this.#extensionRunner.emit({
5265
+ type: "session_switch",
5266
+ reason: "resume",
5267
+ previousSessionFile,
5268
+ });
5269
+ }
4987
5270
 
4988
- // Restore model if saved
4989
- const defaultModelStr = sessionContext.models.default;
4990
- if (defaultModelStr) {
4991
- const slashIdx = defaultModelStr.indexOf("/");
4992
- if (slashIdx > 0) {
4993
- const provider = defaultModelStr.slice(0, slashIdx);
4994
- const modelId = defaultModelStr.slice(slashIdx + 1);
4995
- const availableModels = this.#modelRegistry.getAvailable();
4996
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
4997
- if (match) {
4998
- this.#setModelWithProviderSessionReset(match);
5271
+ this.agent.replaceMessages(sessionContext.messages);
5272
+ this.#syncTodoPhasesFromBranch();
5273
+ if (switchingToDifferentSession) {
5274
+ this.#closeAllProviderSessions("session switch");
5275
+ } else if (didReloadConversationChange) {
5276
+ this.#closeAllProviderSessions("session reload");
5277
+ }
5278
+
5279
+ // Restore model if saved
5280
+ const defaultModelStr = sessionContext.models.default;
5281
+ if (defaultModelStr) {
5282
+ const slashIdx = defaultModelStr.indexOf("/");
5283
+ if (slashIdx > 0) {
5284
+ const provider = defaultModelStr.slice(0, slashIdx);
5285
+ const modelId = defaultModelStr.slice(slashIdx + 1);
5286
+ const availableModels = this.#modelRegistry.getAvailable();
5287
+ const match = availableModels.find(m => m.provider === provider && m.id === modelId);
5288
+ if (match) {
5289
+ const currentModel = this.model;
5290
+ const shouldResetProviderState =
5291
+ switchingToDifferentSession ||
5292
+ (currentModel !== undefined &&
5293
+ (currentModel.provider !== match.provider ||
5294
+ currentModel.id !== match.id ||
5295
+ currentModel.api !== match.api));
5296
+ if (shouldResetProviderState) {
5297
+ this.#setModelWithProviderSessionReset(match);
5298
+ } else {
5299
+ this.agent.setModel(match);
5300
+ }
5301
+ }
4999
5302
  }
5000
5303
  }
5001
- }
5002
-
5003
- const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5004
- const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
5005
- const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5006
5304
 
5007
- if (hasThinkingEntry) {
5008
- this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
5009
- } else {
5010
- const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
5011
- this.#thinkingLevel = effectiveDefaultThinkingLevel;
5012
- this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
5013
- this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
5014
- }
5305
+ const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5306
+ const hasServiceTierEntry = this.sessionManager
5307
+ .getBranch()
5308
+ .some(entry => entry.type === "service_tier_change");
5309
+ const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5310
+ const configuredServiceTier = this.settings.get("serviceTier");
5311
+ const nextThinkingLevel = resolveThinkingLevelForModel(
5312
+ this.model,
5313
+ hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
5314
+ );
5315
+ this.#thinkingLevel = nextThinkingLevel;
5316
+ this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
5317
+ this.agent.serviceTier = hasServiceTierEntry
5318
+ ? sessionContext.serviceTier
5319
+ : configuredServiceTier === "none"
5320
+ ? undefined
5321
+ : configuredServiceTier;
5015
5322
 
5016
- if (hasServiceTierEntry) {
5017
- this.agent.serviceTier = sessionContext.serviceTier;
5018
- } else {
5019
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
5323
+ this.#reconnectToAgent();
5324
+ return true;
5325
+ } catch (error) {
5326
+ this.sessionManager.restoreState(previousSessionState);
5327
+ this.agent.sessionId = previousSessionState.sessionId;
5328
+ let restoreMcpError: unknown;
5329
+ try {
5330
+ await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
5331
+ fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
5332
+ });
5333
+ } catch (mcpError) {
5334
+ restoreMcpError = mcpError;
5335
+ logger.warn("Failed to restore MCP selections after switch error", {
5336
+ previousSessionFile,
5337
+ targetSessionFile: sessionPath,
5338
+ error: String(mcpError),
5339
+ });
5340
+ this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
5341
+ this.agent.setTools(previousTools);
5342
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5343
+ this.agent.setSystemPrompt(previousSystemPrompt);
5344
+ }
5345
+ this.#baseSystemPrompt = previousBaseSystemPrompt;
5346
+ this.agent.setSystemPrompt(previousSystemPrompt);
5347
+ this.agent.replaceMessages(previousAgentMessages);
5348
+ this.#steeringMessages = previousSteeringMessages;
5349
+ this.#followUpMessages = previousFollowUpMessages;
5350
+ this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
5351
+ this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
5352
+ if (previousModel) {
5353
+ this.agent.setModel(previousModel);
5354
+ }
5355
+ this.#thinkingLevel = previousThinkingLevel;
5356
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
5357
+ this.agent.serviceTier = previousServiceTier;
5358
+ this.#syncTodoPhasesFromBranch();
5359
+ this.#reconnectToAgent();
5360
+ if (restoreMcpError) {
5361
+ throw restoreMcpError;
5362
+ }
5363
+ throw error;
5020
5364
  }
5021
-
5022
- this.#reconnectToAgent();
5023
- return true;
5024
5365
  }
5025
5366
 
5026
5367
  /**
@@ -5032,7 +5373,10 @@ export class AgentSession {
5032
5373
  * - selectedText: The text of the selected user message (for editor pre-fill)
5033
5374
  * - cancelled: True if a hook cancelled the branch
5034
5375
  */
5035
- async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
5376
+ async branch(entryId: string): Promise<{
5377
+ selectedText: string;
5378
+ cancelled: boolean;
5379
+ }> {
5036
5380
  const previousSessionFile = this.sessionFile;
5037
5381
  const selectedEntry = this.sessionManager.getEntry(entryId);
5038
5382
 
@@ -5059,6 +5403,7 @@ export class AgentSession {
5059
5403
 
5060
5404
  // Clear pending messages (bound to old session state)
5061
5405
  this.#pendingNextTurnMessages = [];
5406
+ this.#scheduledHiddenNextTurnGeneration = undefined;
5062
5407
 
5063
5408
  // Flush pending writes before branching
5064
5409
  await this.sessionManager.flush();
@@ -5109,7 +5454,12 @@ export class AgentSession {
5109
5454
  async navigateTree(
5110
5455
  targetId: string,
5111
5456
  options: { summarize?: boolean; customInstructions?: string } = {},
5112
- ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
5457
+ ): Promise<{
5458
+ editorText?: string;
5459
+ cancelled: boolean;
5460
+ aborted?: boolean;
5461
+ summaryEntry?: BranchSummaryEntry;
5462
+ }> {
5113
5463
  const oldLeafId = this.sessionManager.getLeafId();
5114
5464
 
5115
5465
  // No-op if already at target