@code-yeongyu/senpi 2026.9.19 → 2026.9.20

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 (171) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/bundle/chunks/{app-server-command-QK7Q6XK5.js → app-server-command-B35FQUTE.js} +1 -1
  3. package/dist/bundle/chunks/{bun-extension-importer-JQZRPNJH.js → bun-extension-importer-HHH4TV4B.js} +3 -3
  4. package/dist/bundle/chunks/{chunk-YRN3PCTM.js → chunk-DQZB4GVV.js} +4 -4
  5. package/dist/bundle/chunks/{chunk-KKDSBABY.js → chunk-E5R67F7G.js} +38 -38
  6. package/dist/bundle/chunks/{chunk-NARL7XFO.js → chunk-TWGC3TBR.js} +2 -2
  7. package/dist/bundle/chunks/{chunk-RLRLQRI2.js → chunk-V2ZB62DG.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-3FRTRCR6.js → chunk-YXQG2SUX.js} +2 -2
  9. package/dist/bundle/chunks/{chunk-PLZK7ABD.js → chunk-Z4LHCTJ6.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-3Q4WGKFA.js → chunk-ZMFHNH6U.js} +1 -1
  11. package/dist/bundle/chunks/{cli-main-7SG3K4K3.js → cli-main-USVT7JMN.js} +1 -1
  12. package/dist/bundle/chunks/{interactive-host-runtime-AITXUDCT.js → interactive-host-runtime-ELBAZ4LN.js} +1 -1
  13. package/dist/bundle/chunks/{interactive-mode-7MN6GGCG.js → interactive-mode-R6225BKI.js} +1 -1
  14. package/dist/bundle/chunks/{list-tips-KTDNGQUK.js → list-tips-Z7T2ACVU.js} +1 -1
  15. package/dist/bundle/chunks/{multi-session-host-WKX34SOP.js → multi-session-host-QNEBJW2P.js} +3 -3
  16. package/dist/bundle/chunks/{package-manager-cli-CB2X57TD.js → package-manager-cli-4MVJ3JSE.js} +1 -1
  17. package/dist/bundle/chunks/{rpc-mode-CI6V2SBW.js → rpc-mode-CXVDRIBU.js} +1 -1
  18. package/dist/bundle/chunks/{session-picker-DXCITGLS.js → session-picker-6N666KQZ.js} +1 -1
  19. package/dist/bundle/chunks/session-worker.js +67 -67
  20. package/dist/bundle/cli.js +1 -1
  21. package/dist/bundle/index.js +1 -1
  22. package/dist/bundle/rpc-entry.js +1 -1
  23. package/dist/core/agent-session-services.d.ts.map +1 -1
  24. package/dist/core/agent-session-services.js +14 -1
  25. package/dist/core/agent-session-services.js.map +1 -1
  26. package/dist/core/agent-session.d.ts +49 -0
  27. package/dist/core/agent-session.d.ts.map +1 -1
  28. package/dist/core/agent-session.js +290 -44
  29. package/dist/core/agent-session.js.map +1 -1
  30. package/dist/core/extensions/builtin/ask-user/extension.d.ts.map +1 -1
  31. package/dist/core/extensions/builtin/ask-user/extension.js +16 -5
  32. package/dist/core/extensions/builtin/ask-user/extension.js.map +1 -1
  33. package/dist/core/extensions/builtin/ask-user/format.d.ts +5 -0
  34. package/dist/core/extensions/builtin/ask-user/format.d.ts.map +1 -1
  35. package/dist/core/extensions/builtin/ask-user/format.js +4 -0
  36. package/dist/core/extensions/builtin/ask-user/format.js.map +1 -1
  37. package/dist/core/extensions/builtin/ask-user/notify.d.ts +2 -0
  38. package/dist/core/extensions/builtin/ask-user/notify.d.ts.map +1 -1
  39. package/dist/core/extensions/builtin/ask-user/notify.js +2 -0
  40. package/dist/core/extensions/builtin/ask-user/notify.js.map +1 -1
  41. package/dist/core/extensions/builtin/ask-user/registry.d.ts +15 -2
  42. package/dist/core/extensions/builtin/ask-user/registry.d.ts.map +1 -1
  43. package/dist/core/extensions/builtin/ask-user/registry.js +13 -0
  44. package/dist/core/extensions/builtin/ask-user/registry.js.map +1 -1
  45. package/dist/core/extensions/builtin/ask-user/render.d.ts +6 -0
  46. package/dist/core/extensions/builtin/ask-user/render.d.ts.map +1 -1
  47. package/dist/core/extensions/builtin/ask-user/render.js +9 -0
  48. package/dist/core/extensions/builtin/ask-user/render.js.map +1 -1
  49. package/dist/core/extensions/builtin/ask-user/resume.d.ts.map +1 -1
  50. package/dist/core/extensions/builtin/ask-user/resume.js +59 -24
  51. package/dist/core/extensions/builtin/ask-user/resume.js.map +1 -1
  52. package/dist/core/extensions/builtin/ask-user/tool.d.ts +1 -1
  53. package/dist/core/extensions/builtin/ask-user/tool.d.ts.map +1 -1
  54. package/dist/core/extensions/builtin/ask-user/tool.js +98 -17
  55. package/dist/core/extensions/builtin/ask-user/tool.js.map +1 -1
  56. package/dist/core/extensions/builtin/compaction/model-usability-budget.d.ts +18 -0
  57. package/dist/core/extensions/builtin/compaction/model-usability-budget.d.ts.map +1 -1
  58. package/dist/core/extensions/builtin/compaction/model-usability-budget.js +16 -8
  59. package/dist/core/extensions/builtin/compaction/model-usability-budget.js.map +1 -1
  60. package/dist/core/extensions/builtin/compaction/switch-admission.d.ts +23 -0
  61. package/dist/core/extensions/builtin/compaction/switch-admission.d.ts.map +1 -0
  62. package/dist/core/extensions/builtin/compaction/switch-admission.js +19 -0
  63. package/dist/core/extensions/builtin/compaction/switch-admission.js.map +1 -0
  64. package/dist/core/extensions/builtin/goal/index.d.ts.map +1 -1
  65. package/dist/core/extensions/builtin/goal/index.js +10 -0
  66. package/dist/core/extensions/builtin/goal/index.js.map +1 -1
  67. package/dist/core/extensions/bun-extension-commonjs.d.ts +1 -1
  68. package/dist/core/extensions/bun-extension-commonjs.d.ts.map +1 -1
  69. package/dist/core/extensions/bun-extension-commonjs.js +3 -3
  70. package/dist/core/extensions/bun-extension-commonjs.js.map +1 -1
  71. package/dist/core/extensions/bun-extension-importer.d.ts.map +1 -1
  72. package/dist/core/extensions/bun-extension-importer.js +4 -1
  73. package/dist/core/extensions/bun-extension-importer.js.map +1 -1
  74. package/dist/core/extensions/bun-extension-registry.d.ts.map +1 -1
  75. package/dist/core/extensions/bun-extension-registry.js +5 -9
  76. package/dist/core/extensions/bun-extension-registry.js.map +1 -1
  77. package/dist/core/resolved-paths-memo.d.ts +31 -0
  78. package/dist/core/resolved-paths-memo.d.ts.map +1 -0
  79. package/dist/core/resolved-paths-memo.js +57 -0
  80. package/dist/core/resolved-paths-memo.js.map +1 -0
  81. package/dist/core/resource-loader.d.ts +4 -1
  82. package/dist/core/resource-loader.d.ts.map +1 -1
  83. package/dist/core/resource-loader.js +25 -12
  84. package/dist/core/resource-loader.js.map +1 -1
  85. package/dist/core/retry-fallback/settings.d.ts +10 -2
  86. package/dist/core/retry-fallback/settings.d.ts.map +1 -1
  87. package/dist/core/retry-fallback/settings.js +28 -6
  88. package/dist/core/retry-fallback/settings.js.map +1 -1
  89. package/dist/core/skill-discovery.d.ts.map +1 -1
  90. package/dist/core/skill-discovery.js +11 -1
  91. package/dist/core/skill-discovery.js.map +1 -1
  92. package/dist/main.d.ts +10 -0
  93. package/dist/main.d.ts.map +1 -1
  94. package/dist/main.js +19 -2
  95. package/dist/main.js.map +1 -1
  96. package/dist/modes/interactive/components/ask-user-answer-chip.d.ts +2 -5
  97. package/dist/modes/interactive/components/ask-user-answer-chip.d.ts.map +1 -1
  98. package/dist/modes/interactive/components/ask-user-answer-chip.js +1 -4
  99. package/dist/modes/interactive/components/ask-user-answer-chip.js.map +1 -1
  100. package/dist/modes/interactive/components/assistant-message.d.ts +2 -0
  101. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  102. package/dist/modes/interactive/components/assistant-message.js +8 -0
  103. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  104. package/dist/modes/interactive/components/assistant-render-descriptors.d.ts +1 -0
  105. package/dist/modes/interactive/components/assistant-render-descriptors.d.ts.map +1 -1
  106. package/dist/modes/interactive/components/assistant-render-descriptors.js +4 -0
  107. package/dist/modes/interactive/components/assistant-render-descriptors.js.map +1 -1
  108. package/dist/modes/interactive/components/status-indicator.d.ts +3 -1
  109. package/dist/modes/interactive/components/status-indicator.d.ts.map +1 -1
  110. package/dist/modes/interactive/components/status-indicator.js +15 -2
  111. package/dist/modes/interactive/components/status-indicator.js.map +1 -1
  112. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  113. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  114. package/dist/modes/interactive/interactive-mode.js +81 -6
  115. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  116. package/dist/modes/interactive/provider-error-presentation.d.ts +18 -0
  117. package/dist/modes/interactive/provider-error-presentation.d.ts.map +1 -0
  118. package/dist/modes/interactive/provider-error-presentation.js +100 -0
  119. package/dist/modes/interactive/provider-error-presentation.js.map +1 -0
  120. package/dist/modes/rpc/rpc-types.d.ts +18 -0
  121. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  122. package/dist/modes/rpc/rpc-types.js.map +1 -1
  123. package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
  124. package/dist/modes/rpc/session-command-router.js +14 -0
  125. package/dist/modes/rpc/session-command-router.js.map +1 -1
  126. package/dist/modes/rpc/session-event-writer.d.ts +9 -1
  127. package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
  128. package/dist/modes/rpc/session-event-writer.js +21 -0
  129. package/dist/modes/rpc/session-event-writer.js.map +1 -1
  130. package/dist/modes/rpc/session-registry.d.ts.map +1 -1
  131. package/dist/modes/rpc/session-registry.js +9 -2
  132. package/dist/modes/rpc/session-registry.js.map +1 -1
  133. package/dist/modes/rpc/session-worker-requests.d.ts +10 -1
  134. package/dist/modes/rpc/session-worker-requests.d.ts.map +1 -1
  135. package/dist/modes/rpc/session-worker-requests.js +8 -2
  136. package/dist/modes/rpc/session-worker-requests.js.map +1 -1
  137. package/dist/modes/rpc/session-worker-requests.test.d.ts +2 -0
  138. package/dist/modes/rpc/session-worker-requests.test.d.ts.map +1 -0
  139. package/dist/modes/rpc/session-worker-requests.test.js +44 -0
  140. package/dist/modes/rpc/session-worker-requests.test.js.map +1 -0
  141. package/docs/rpc.md +7 -0
  142. package/docs/settings.md +1 -1
  143. package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +24 -0
  144. package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
  145. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  146. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  147. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
  148. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode.json +1 -1
  149. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opengateway.json +1 -1
  150. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  151. package/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  152. package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
  153. package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai.json +1 -1
  154. package/node_modules/@earendil-works/pi-ai/package.json +2 -2
  155. package/node_modules/@earendil-works/pi-pty/package.json +1 -1
  156. package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
  157. package/node_modules/@earendil-works/pi-tui/dist/native-module-path.d.ts.map +1 -1
  158. package/node_modules/@earendil-works/pi-tui/dist/native-module-path.js +26 -3
  159. package/node_modules/@earendil-works/pi-tui/dist/native-module-path.js.map +1 -1
  160. package/node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.d.ts.map +1 -1
  161. package/node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js +13 -6
  162. package/node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js.map +1 -1
  163. package/node_modules/@earendil-works/pi-tui/dist/tui-main-screen.d.ts.map +1 -1
  164. package/node_modules/@earendil-works/pi-tui/dist/tui-main-screen.js +11 -3
  165. package/node_modules/@earendil-works/pi-tui/dist/tui-main-screen.js.map +1 -1
  166. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts +15 -2
  167. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts.map +1 -1
  168. package/node_modules/@earendil-works/pi-tui/dist/tui.js +45 -2
  169. package/node_modules/@earendil-works/pi-tui/dist/tui.js.map +1 -1
  170. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  171. package/package.json +8 -8
@@ -50,7 +50,8 @@ import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "./extensions/builtin/claude-sdk-oa
50
50
  import { ModelUsabilityBudgetError, projectModelUsabilityBudget, } from "./extensions/builtin/compaction/model-usability-budget.js";
51
51
  import { resolveEffectiveReserveTokens, resolveReserveTokens, shouldTriggerCompaction, } from "./extensions/builtin/compaction/policy.js";
52
52
  import { createResumeCompactionRequirement, } from "./extensions/builtin/compaction/resume-admission.js";
53
- import { RESUME_SLICE_ORIGIN, RESUME_SLICE_SCHEMA, resumeSliceNotice, } from "./extensions/builtin/compaction/resume-slice.js";
53
+ import { planResumeSlice, RESUME_SLICE_ORIGIN, RESUME_SLICE_SCHEMA, resumeSliceNotice, } from "./extensions/builtin/compaction/resume-slice.js";
54
+ import { createPendingModelSwitch, pendingSwitchKeepRecentTokens, } from "./extensions/builtin/compaction/switch-admission.js";
54
55
  import { WAKE_SOURCE_STATE_EVENT } from "./extensions/builtin/monitor-state-event.js";
55
56
  import { CODEX_RESPONSES_API } from "./extensions/builtin/service-tier.js";
56
57
  import { deriveExtensionRegistrationId } from "./extensions/builtin/tool-search/engine/marker.js";
@@ -440,6 +441,8 @@ export class AgentSession {
440
441
  modelSelectSource: reason,
441
442
  invalidateCompaction: true,
442
443
  ephemeralThinkingLevel: thinking,
444
+ allowDeferral: false,
445
+ repairWithSlice: true,
443
446
  });
444
447
  },
445
448
  emit: (event) => this._emit(event),
@@ -1081,17 +1084,30 @@ export class AgentSession {
1081
1084
  this._delegatedCompactionKey = undefined;
1082
1085
  this._incrementMessageRevision();
1083
1086
  }
1087
+ /**
1088
+ * #1873 narrows #1378: a cycle silently passes over a candidate only when no
1089
+ * reduction could ever make it serve. A candidate that one compaction would fix
1090
+ * is landed on and held as a pending switch instead, because skipping it throws
1091
+ * away the model the user was cycling towards.
1092
+ */
1084
1093
  _modelChangeWouldExhaustContext(model) {
1085
1094
  if (model.contextWindow <= 0)
1086
1095
  return undefined;
1096
+ const compaction = this._getCompactionSettings();
1087
1097
  const projection = projectModelUsabilityBudget({
1088
1098
  model,
1089
1099
  systemPrompt: this.agent.state.systemPrompt,
1090
1100
  tools: this.agent.state.tools,
1091
1101
  liveContextTokens: this._getDownswitchLiveContextTokens(model),
1092
- compaction: this._getCompactionSettings(),
1102
+ compaction,
1103
+ includeSpeculationLead: false,
1104
+ admission: this._modelSwitchAdmission(),
1093
1105
  });
1094
- return projection.usable ? undefined : projection;
1106
+ if (projection.usable)
1107
+ return undefined;
1108
+ if (compaction.enabled && projection.verdict === "fits-after-compaction")
1109
+ return undefined;
1110
+ return projection;
1095
1111
  }
1096
1112
  _getIdleWaitPromise() {
1097
1113
  if (!this._idleWaitPromise) {
@@ -2936,30 +2952,7 @@ export class AgentSession {
2936
2952
  }
2937
2953
  // Emit before_agent_start extension event
2938
2954
  const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPrompt, this._baseSystemPromptOptions);
2939
- // Add all custom messages from extensions
2940
- if (result?.messages) {
2941
- for (const msg of result.messages) {
2942
- messages.push({
2943
- role: "custom",
2944
- customType: msg.customType,
2945
- // Untyped extensions can pass null/missing content; normalize at ingestion.
2946
- content: msg.content ?? [],
2947
- display: msg.display,
2948
- details: msg.details,
2949
- timestamp: Date.now(),
2950
- });
2951
- }
2952
- }
2953
- // Apply extension-modified system prompt, or reset to base
2954
- if (result?.systemPrompt !== undefined) {
2955
- this._systemPromptOverride = result.systemPrompt;
2956
- this.agent.state.systemPrompt = result.systemPrompt;
2957
- }
2958
- else {
2959
- // Ensure we're using the base prompt (in case previous turn had modifications)
2960
- this._systemPromptOverride = undefined;
2961
- this.agent.state.systemPrompt = this._baseSystemPrompt;
2962
- }
2955
+ this._applyBeforeAgentStartResult(messages, result);
2963
2956
  // The preflight above only sees persisted session context. These prompt,
2964
2957
  // next-turn, and before_agent_start additions are also provider-visible,
2965
2958
  // so make one final admission decision against the complete request.
@@ -3008,6 +3001,31 @@ export class AgentSession {
3008
3001
  this._startSessionTitleGeneration(titlePrompt);
3009
3002
  }
3010
3003
  }
3004
+ /** Apply before_agent_start additions and reset any prior turn's system-prompt override. */
3005
+ _applyBeforeAgentStartResult(messages, result) {
3006
+ if (result?.messages) {
3007
+ for (const msg of result.messages) {
3008
+ messages.push({
3009
+ role: "custom",
3010
+ customType: msg.customType,
3011
+ // Untyped extensions can pass null/missing content; normalize at ingestion.
3012
+ content: msg.content ?? [],
3013
+ display: msg.display,
3014
+ details: msg.details,
3015
+ timestamp: Date.now(),
3016
+ });
3017
+ }
3018
+ }
3019
+ if (result?.systemPrompt !== undefined) {
3020
+ this._systemPromptOverride = result.systemPrompt;
3021
+ this.agent.state.systemPrompt = result.systemPrompt;
3022
+ }
3023
+ else {
3024
+ // Ensure we're using the base prompt (in case a previous turn had modifications).
3025
+ this._systemPromptOverride = undefined;
3026
+ this.agent.state.systemPrompt = this._baseSystemPrompt;
3027
+ }
3028
+ }
3011
3029
  /**
3012
3030
  * Try to execute an extension command. Returns true if command was found and executed.
3013
3031
  */
@@ -3384,24 +3402,43 @@ export class AgentSession {
3384
3402
  }
3385
3403
  }
3386
3404
  else if (options?.triggerTurn) {
3405
+ finishSessionWork ??= this._sessionWorkBarrier.begin();
3406
+ const messages = [appMessage];
3407
+ const queueTriggerForLater = () => {
3408
+ if (options.deliverAs === "followUp") {
3409
+ this.agent.followUp(appMessage);
3410
+ }
3411
+ else {
3412
+ this.agent.steer(appMessage);
3413
+ }
3414
+ };
3415
+ this._triggerTurnAdmissionAbortGeneration = userAbortGeneration;
3387
3416
  try {
3388
3417
  await this._enforceCompactionBeforeProvider(this._findLastAssistantMessage(), false, "pre_prompt");
3389
- await this._enforceFinalProviderAdmission([appMessage]);
3418
+ const result = await this._extensionRunner.emitBeforeAgentStart(contentText(appMessage.content, ""), undefined, this._baseSystemPrompt, this._baseSystemPromptOptions);
3419
+ if (userAbortGeneration !== this._userAbortGeneration) {
3420
+ queueTriggerForLater();
3421
+ return;
3422
+ }
3423
+ this._applyBeforeAgentStartResult(messages, result);
3424
+ await this._enforceFinalProviderAdmission(messages);
3425
+ if (userAbortGeneration !== this._userAbortGeneration) {
3426
+ queueTriggerForLater();
3427
+ return;
3428
+ }
3390
3429
  }
3391
3430
  catch (error) {
3392
3431
  // Mirror sendUserMessage's retention contract: an admission
3393
3432
  // rejection must retain the message for later delivery instead
3394
3433
  // of silently dropping it (the fire-and-forget extension action
3395
3434
  // swallows this rejection).
3396
- if (options.deliverAs === "followUp") {
3397
- this.agent.followUp(appMessage);
3398
- }
3399
- else {
3400
- this.agent.steer(appMessage);
3401
- }
3435
+ queueTriggerForLater();
3402
3436
  throw error;
3403
3437
  }
3404
- await this._promptAgent(appMessage, deferredTurnClaim);
3438
+ finally {
3439
+ this._triggerTurnAdmissionAbortGeneration = undefined;
3440
+ }
3441
+ await this._promptAgent(messages, deferredTurnClaim);
3405
3442
  }
3406
3443
  else if (this.isStreaming) {
3407
3444
  this._pendingCustomMessages.push(appMessage);
@@ -3593,12 +3630,15 @@ export class AgentSession {
3593
3630
  // Streaming aborts are carried by agent_end provenance; only gaps need session_abort.
3594
3631
  const wasMidRun = this.isStreaming && this._retryAbortController === undefined;
3595
3632
  const hadRetryBackoff = this._retryAbortController !== undefined;
3596
- const hadCompactionOrPending = !this.isStreaming && (this.isCompacting || this.pendingMessageCount > 0);
3633
+ const hasPendingTriggerTurnAdmission = this._triggerTurnAdmissionAbortGeneration === this._userAbortGeneration;
3634
+ const hadCompactionOrPending = !this.isStreaming && (this.isCompacting || this.pendingMessageCount > 0 || hasPendingTriggerTurnAdmission);
3597
3635
  const hadClearedQueues = this._hadClearedQueuedMessages;
3598
3636
  const joinedAgentEndBoundary = this._abortProvenance.hasOpenAgentEndBoundary;
3599
3637
  this._hadClearedQueuedMessages = false;
3600
3638
  const shouldEmitAbort = !joinedAgentEndBoundary && !wasMidRun && (hadRetryBackoff || hadCompactionOrPending || hadClearedQueues);
3601
3639
  this.abortCompaction();
3640
+ if (hasPendingTriggerTurnAdmission)
3641
+ this._recordUserAbort();
3602
3642
  await this._abortActiveAgentAndRetry("user");
3603
3643
  if (!shouldEmitAbort)
3604
3644
  return;
@@ -3678,6 +3718,120 @@ export class AgentSession {
3678
3718
  throw new ModelUsabilityBudgetError(projection);
3679
3719
  }
3680
3720
  /** Admit an oversized restored transcript so the normal pre-provider compaction can run. */
3721
+ /** The switch waiting for the next send to compact for it, if any (#1873). */
3722
+ get pendingModelSwitch() {
3723
+ return this._pendingModelSwitch;
3724
+ }
3725
+ /**
3726
+ * A switch the target cannot hold yet but one compaction would fix. Refusing it
3727
+ * discards what the user asked for; committing it strands the session on a model
3728
+ * that cannot answer. Holding it does neither: nothing durable is written until
3729
+ * the next send has reduced the transcript and the switch actually applies.
3730
+ */
3731
+ _projectSwitchDeferral(model, liveContextTokens) {
3732
+ if (model.contextWindow <= 0)
3733
+ return undefined;
3734
+ const compaction = this._getCompactionSettings();
3735
+ if (!compaction.enabled)
3736
+ return undefined;
3737
+ if (this._modelSwitchAdmission() !== "switch")
3738
+ return undefined;
3739
+ const projection = projectModelUsabilityBudget({
3740
+ model,
3741
+ systemPrompt: this.agent.state.systemPrompt,
3742
+ tools: this.agent.state.tools,
3743
+ liveContextTokens,
3744
+ compaction,
3745
+ includeSpeculationLead: false,
3746
+ admission: "switch",
3747
+ });
3748
+ return projection.verdict === "fits-after-compaction" ? projection : undefined;
3749
+ }
3750
+ _admitSwitchCompactionRequired(model, projection, persistDefault) {
3751
+ const pending = createPendingModelSwitch({ model, projection, persistDefault });
3752
+ this._pendingModelSwitch = pending;
3753
+ this._emit({
3754
+ type: "model_change_pending",
3755
+ model,
3756
+ contextWindow: projection.contextWindow,
3757
+ liveContextTokens: projection.liveContextTokens,
3758
+ requiredTokens: projection.requiredTokens,
3759
+ shortfallTokens: projection.shortfallTokens,
3760
+ notice: pending.notice,
3761
+ });
3762
+ }
3763
+ /**
3764
+ * Fit a fallback rung by reducing the transcript without a provider request
3765
+ * (#1873). Summarizing is not available here: the model being fallen back from
3766
+ * has usually just failed, and the rung itself cannot hold the transcript, so
3767
+ * the deterministic slice is the only reduction that can run. The recorded
3768
+ * transcript stays intact in the session file.
3769
+ */
3770
+ _reduceForSwitchTarget(model, liveContextTokens) {
3771
+ if (!this._getCompactionSettings().enabled)
3772
+ return liveContextTokens;
3773
+ const projection = this._projectSwitchFit(model);
3774
+ if (projection.usable || projection.verdict !== "fits-after-compaction")
3775
+ return liveContextTokens;
3776
+ const plan = planResumeSlice({ entries: this.sessionManager.getBranch(), projection });
3777
+ if (!plan)
3778
+ return liveContextTokens;
3779
+ this.applyResumeSlice(plan);
3780
+ return this._projectSwitchFit(model).liveContextTokens;
3781
+ }
3782
+ _projectSwitchFit(model) {
3783
+ // Measured per message rather than through the context estimate: a retained
3784
+ // turn keeps the usage it reported before the reduction, so a usage-derived
3785
+ // total cannot observe the compaction that just ran (the sdk.ts convention).
3786
+ const liveContextTokens = filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages).reduce((total, message) => total + estimateTokens(message), 0);
3787
+ return projectModelUsabilityBudget({
3788
+ model,
3789
+ systemPrompt: this.agent.state.systemPrompt,
3790
+ tools: this.agent.state.tools,
3791
+ liveContextTokens,
3792
+ compaction: this._getCompactionSettings(),
3793
+ includeSpeculationLead: false,
3794
+ admission: "switch",
3795
+ });
3796
+ }
3797
+ /**
3798
+ * Repair and apply a held switch, in the one order that works: the model that
3799
+ * can still hold the transcript writes the summary, the reduction aims at the
3800
+ * window the transcript must end up inside, and only a transcript that actually
3801
+ * fits is allowed to carry the switch. The deterministic slice is the floor when
3802
+ * summarization did not get there, so a repairable switch cannot strand itself.
3803
+ */
3804
+ async _applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason) {
3805
+ const pending = this._pendingModelSwitch;
3806
+ if (pending === undefined)
3807
+ return false;
3808
+ if (!this.model)
3809
+ throw new RequiredCompactionError();
3810
+ await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason, false, false, pendingSwitchKeepRecentTokens(pending.projection));
3811
+ let projection = this._projectSwitchFit(pending.model);
3812
+ if (!projection.usable) {
3813
+ const plan = planResumeSlice({ entries: this.sessionManager.getBranch(), projection });
3814
+ if (plan) {
3815
+ this.applyResumeSlice(plan);
3816
+ projection = this._projectSwitchFit(pending.model);
3817
+ }
3818
+ }
3819
+ this._pendingModelSwitch = undefined;
3820
+ if (!projection.usable) {
3821
+ const error = new ModelUsabilityBudgetError(projection);
3822
+ this._recordRejectedModelChange(pending.model, error);
3823
+ throw error;
3824
+ }
3825
+ await this._switchActiveModel(pending.model, {
3826
+ persistDefault: pending.persistDefault,
3827
+ appendSessionEntry: true,
3828
+ emitModelSelect: true,
3829
+ modelSelectSource: "set",
3830
+ invalidateCompaction: true,
3831
+ allowDeferral: false,
3832
+ });
3833
+ return true;
3834
+ }
3681
3835
  admitResumeCompactionRequired(projection) {
3682
3836
  this._resumeCompactionRequirement = createResumeCompactionRequirement(projection);
3683
3837
  }
@@ -3783,8 +3937,17 @@ export class AgentSession {
3783
3937
  * observable on one path and invisible on its sibling.
3784
3938
  */
3785
3939
  _assertModelUsableForSwitch(model, liveContextTokens) {
3940
+ const admission = this._modelSwitchAdmission();
3786
3941
  try {
3787
- this.assertModelUsable(model, liveContextTokens, { admission: this._modelSwitchAdmission() });
3942
+ // #1873: a live switch only has to fit the next single request. Speculation
3943
+ // runs after the switch is admitted and cannot shrink a transcript it has not
3944
+ // been admitted to yet - the reasoning #1339 applied to resume, which this
3945
+ // extends to the switch path. The cold-start floor (`start`) keeps charging
3946
+ // the lead, because there it is a property of the model, not of a transcript.
3947
+ this.assertModelUsable(model, liveContextTokens, {
3948
+ admission,
3949
+ includeSpeculationLead: admission === "start",
3950
+ });
3788
3951
  }
3789
3952
  catch (error) {
3790
3953
  this._recordRejectedModelChange(model, error);
@@ -3792,12 +3955,23 @@ export class AgentSession {
3792
3955
  }
3793
3956
  }
3794
3957
  async _setModel(model, updateGlobalDefaults) {
3795
- this._assertModelUsableForSwitch(model, this._getDownswitchLiveContextTokens(model));
3958
+ const liveContextTokens = this._getDownswitchLiveContextTokens(model);
3959
+ const deferral = this._projectSwitchDeferral(model, liveContextTokens);
3960
+ if (deferral === undefined) {
3961
+ this._assertModelUsableForSwitch(model, liveContextTokens);
3962
+ }
3796
3963
  if (!(await this._modelRuntime.checkAuth(model.provider))) {
3797
3964
  const error = new Error(`No API key for ${model.provider}/${model.id}`);
3798
3965
  this._recordRejectedModelChange(model, error);
3799
3966
  throw error;
3800
3967
  }
3968
+ // Auth is the last check that can refuse outright, so the switch is only held
3969
+ // once it is otherwise good: a pending switch to a model with no key would
3970
+ // surface its failure a whole message later.
3971
+ if (deferral !== undefined) {
3972
+ this._admitSwitchCompactionRequired(model, deferral, updateGlobalDefaults);
3973
+ return undefined;
3974
+ }
3801
3975
  // A manual model change abandons any active fallback window; if a fallback
3802
3976
  // retry sleep is still pending, cancel it so no surprise continuation fires.
3803
3977
  const hadActiveFallback = this._retryFallback.activeState !== undefined;
@@ -3844,6 +4018,11 @@ export class AgentSession {
3844
4018
  }
3845
4019
  const thinking = this._getThinkingForModelSwitch(model, opts.ephemeralThinkingLevel);
3846
4020
  const liveContextTokens = this._getDownswitchLiveContextTokens(model);
4021
+ // Snapshot before any mutation: the baseline reset below used to run first, so the
4022
+ // "previous" value captured the already-cleared one and a refused switch restored
4023
+ // `undefined` instead of the baseline the session came in with.
4024
+ const previousReasoningBaseline = this.agent.state.reasoningBaseline;
4025
+ const previousAbortServerSideFallback = this.agent.abortServerSideFallback;
3847
4026
  this.agent.state.model = model;
3848
4027
  if (!(model.id === "gpt-6-astra" && (model.provider === "openai" || model.provider === "openai-codex"))) {
3849
4028
  this.agent.state.reasoningBaseline = undefined;
@@ -3853,8 +4032,6 @@ export class AgentSession {
3853
4032
  const previousFastMode = this.isFastModeActive();
3854
4033
  const previousThinkingLevel = this.agent.state.thinkingLevel;
3855
4034
  const previousThinkingSelection = this.agent.state.thinkingSelection;
3856
- const previousReasoningBaseline = this.agent.state.reasoningBaseline;
3857
- const previousAbortServerSideFallback = this.agent.abortServerSideFallback;
3858
4035
  this.agent.abortServerSideFallback =
3859
4036
  this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain();
3860
4037
  this._currentServiceTier = this._resolveServiceTier(model, scopedMatch?.serviceTier);
@@ -3870,12 +4047,35 @@ export class AgentSession {
3870
4047
  const systemPromptChange = opts.emitModelSelect
3871
4048
  ? await this._emitModelSelect(model, previousModel, opts.modelSelectSource)
3872
4049
  : undefined;
3873
- this._assertModelUsableForSwitch(model, liveContextTokens);
4050
+ const deferral = opts.allowDeferral === false ? undefined : this._projectSwitchDeferral(model, liveContextTokens);
4051
+ if (deferral !== undefined) {
4052
+ // Roll the in-memory selection back: the switch is held, not applied, so
4053
+ // nothing may observe the target as active until the next send repairs it.
4054
+ if (previousModel)
4055
+ this.agent.state.model = previousModel;
4056
+ this.agent.state.systemPrompt = previousSystemPrompt;
4057
+ this.agent.state.thinkingLevel = previousThinkingLevel;
4058
+ this.agent.state.thinkingSelection = previousThinkingSelection;
4059
+ this.agent.state.reasoningBaseline = previousReasoningBaseline;
4060
+ this.agent.abortServerSideFallback = previousAbortServerSideFallback;
4061
+ this._currentServiceTier = previousTier;
4062
+ this._admitSwitchCompactionRequired(model, deferral, opts.persistDefault);
4063
+ return undefined;
4064
+ }
4065
+ const admittedLiveContextTokens = opts.repairWithSlice
4066
+ ? this._reduceForSwitchTarget(model, liveContextTokens)
4067
+ : liveContextTokens;
4068
+ this._assertModelUsableForSwitch(model, admittedLiveContextTokens);
3874
4069
  if (opts.appendSessionEntry) {
3875
4070
  this.sessionManager.appendModelChange(model.provider, model.id, opts.entryReason, previousModel?.provider, previousModel?.id);
3876
4071
  }
3877
4072
  if (opts.persistDefault)
3878
4073
  this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
4074
+ // A switch that actually landed supersedes anything still being held (#1873).
4075
+ // Without this a held switch survives its own resolution - an explicit
4076
+ // /compact followed by a manual retry applies the switch here, and the stale
4077
+ // hold would compact and re-switch again on the next message.
4078
+ this._pendingModelSwitch = undefined;
3879
4079
  // Emit only after all admission hooks have accepted the candidate.
3880
4080
  this._emit({
3881
4081
  type: "model_changed",
@@ -3970,7 +4170,14 @@ export class AgentSession {
3970
4170
  const alternatives = favoriteModels.filter((entry) => !modelsAreEqual(entry.model, currentModel));
3971
4171
  const onlyAlternative = alternatives.length === 1 ? alternatives[0] : undefined;
3972
4172
  if (onlyAlternative) {
3973
- this._assertModelUsableForSwitch(onlyAlternative.model, this._getDownswitchLiveContextTokens(onlyAlternative.model));
4173
+ const onlyLiveContextTokens = this._getDownswitchLiveContextTokens(onlyAlternative.model);
4174
+ const onlyDeferral = this._projectSwitchDeferral(onlyAlternative.model, onlyLiveContextTokens);
4175
+ if (onlyDeferral !== undefined) {
4176
+ this._admitSwitchCompactionRequired(onlyAlternative.model, onlyDeferral, true);
4177
+ }
4178
+ else {
4179
+ this._assertModelUsableForSwitch(onlyAlternative.model, onlyLiveContextTokens);
4180
+ }
3974
4181
  }
3975
4182
  return {
3976
4183
  model: currentModel,
@@ -3981,6 +4188,18 @@ export class AgentSession {
3981
4188
  }
3982
4189
  const next = favoriteModels[selectedIndex];
3983
4190
  const liveContextTokens = this._getDownswitchLiveContextTokens(next.model);
4191
+ // #1873: the cycle lands on a candidate one compaction would admit instead of
4192
+ // refusing it. Nothing is mutated yet, so holding here needs no rollback.
4193
+ const cycleDeferral = this._projectSwitchDeferral(next.model, liveContextTokens);
4194
+ if (cycleDeferral !== undefined) {
4195
+ this._admitSwitchCompactionRequired(next.model, cycleDeferral, true);
4196
+ return {
4197
+ model: currentModel,
4198
+ thinkingLevel: this.thinkingLevel,
4199
+ isScoped: true,
4200
+ skippedModels,
4201
+ };
4202
+ }
3984
4203
  this._assertModelUsableForSwitch(next.model, liveContextTokens);
3985
4204
  const invalidatesCompaction = this._modelSelectionChangesContext(currentModel, next.model) ||
3986
4205
  currentModel?.provider !== next.model.provider ||
@@ -4005,9 +4224,29 @@ export class AgentSession {
4005
4224
  // default - may be written before this guard accepts, or a refused cycle
4006
4225
  // would resume on a model that never ran (the ordering `_switchActiveModel`
4007
4226
  // already uses).
4227
+ // #1873: a prompt that only grew past the budget is held, not refused; the
4228
+ // in-memory selection rolls back exactly as the catch below would.
4229
+ const postHookDeferral = this._projectSwitchDeferral(next.model, liveContextTokens);
4230
+ if (postHookDeferral !== undefined) {
4231
+ if (currentModel)
4232
+ this.agent.state.model = currentModel;
4233
+ this.agent.state.systemPrompt = previousSystemPrompt;
4234
+ this._currentServiceTier = previousTier;
4235
+ this._admitSwitchCompactionRequired(next.model, postHookDeferral, true);
4236
+ return {
4237
+ model: currentModel,
4238
+ thinkingLevel: this.thinkingLevel,
4239
+ isScoped: true,
4240
+ skippedModels,
4241
+ };
4242
+ }
4008
4243
  this._assertModelUsableForSwitch(next.model, liveContextTokens);
4009
4244
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
4010
4245
  this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
4246
+ // #1873: the cycle commits here rather than through `_switchActiveModel`, so it
4247
+ // has to supersede a held switch itself - otherwise cycling onto a model that
4248
+ // fits leaves an older hold to reclaim the session on the next message.
4249
+ this._pendingModelSwitch = undefined;
4011
4250
  // Post-switch, same contract as _switchActiveModel: the level in force AFTER the cycle.
4012
4251
  this._emit({
4013
4252
  type: "model_changed",
@@ -4638,7 +4877,10 @@ export class AgentSession {
4638
4877
  throw new CompactionCancelledError();
4639
4878
  }
4640
4879
  const pathEntries = this.sessionManager.getBranch();
4641
- const settings = cursorOverflowCompactionSettings(this._getCompactionSettings(), this.model?.provider, request.reason);
4880
+ const resolvedSettings = this._getCompactionSettings();
4881
+ const settings = cursorOverflowCompactionSettings(request.keepRecentTokensOverride === undefined
4882
+ ? resolvedSettings
4883
+ : { ...resolvedSettings, keepRecentTokens: request.keepRecentTokensOverride }, this.model?.provider, request.reason);
4642
4884
  let compactionResult = request.precomputed;
4643
4885
  let fromExtension = request.precomputed !== undefined;
4644
4886
  if (!compactionResult) {
@@ -4965,6 +5207,9 @@ export class AgentSession {
4965
5207
  }
4966
5208
  const settings = this._getCompactionSettings();
4967
5209
  const model = this.model;
5210
+ if (this._pendingModelSwitch !== undefined) {
5211
+ return await this._applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason);
5212
+ }
4968
5213
  if (this._resumeCompactionRequirement !== undefined) {
4969
5214
  if (!model)
4970
5215
  throw new RequiredCompactionError();
@@ -5281,7 +5526,7 @@ export class AgentSession {
5281
5526
  this._delegatedCompactionKey.provider === model.provider &&
5282
5527
  this._delegatedCompactionKey.id === model.id);
5283
5528
  }
5284
- async _runPrePromptCompaction(lastAssistantMessage, skipAbortedCheck, reason = "pre_prompt", willRetry = false, allowSummaryOnly = false) {
5529
+ async _runPrePromptCompaction(lastAssistantMessage, skipAbortedCheck, reason = "pre_prompt", willRetry = false, allowSummaryOnly = false, keepRecentTokensOverride) {
5285
5530
  if (this._isCompactionDelegated())
5286
5531
  return false;
5287
5532
  const controller = new AbortController();
@@ -5298,6 +5543,7 @@ export class AgentSession {
5298
5543
  lastAssistantMessage,
5299
5544
  skipAbortedCheck,
5300
5545
  allowSummaryOnly,
5546
+ keepRecentTokensOverride,
5301
5547
  });
5302
5548
  if (!execution.accepted &&
5303
5549
  lastAssistantMessage &&