@code-yeongyu/senpi 2026.9.19-2 → 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 +53 -0
  2. package/dist/bundle/chunks/{app-server-command-QBJ4GPN7.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-EHSDTQ6I.js → chunk-E5R67F7G.js} +37 -37
  6. package/dist/bundle/chunks/{chunk-5E5TLB76.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-MLJ2H637.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-7C75IXNF.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-2YCJKQOL.js → multi-session-host-QNEBJW2P.js} +3 -3
  16. package/dist/bundle/chunks/{package-manager-cli-4ANSELYT.js → package-manager-cli-4MVJ3JSE.js} +1 -1
  17. package/dist/bundle/chunks/{rpc-mode-OPFQIWFP.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 +66 -66
  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 +45 -0
  27. package/dist/core/agent-session.d.ts.map +1 -1
  28. package/dist/core/agent-session.js +233 -11
  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 +12 -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) {
@@ -3702,6 +3718,120 @@ export class AgentSession {
3702
3718
  throw new ModelUsabilityBudgetError(projection);
3703
3719
  }
3704
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
+ }
3705
3835
  admitResumeCompactionRequired(projection) {
3706
3836
  this._resumeCompactionRequirement = createResumeCompactionRequirement(projection);
3707
3837
  }
@@ -3807,8 +3937,17 @@ export class AgentSession {
3807
3937
  * observable on one path and invisible on its sibling.
3808
3938
  */
3809
3939
  _assertModelUsableForSwitch(model, liveContextTokens) {
3940
+ const admission = this._modelSwitchAdmission();
3810
3941
  try {
3811
- 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
+ });
3812
3951
  }
3813
3952
  catch (error) {
3814
3953
  this._recordRejectedModelChange(model, error);
@@ -3816,12 +3955,23 @@ export class AgentSession {
3816
3955
  }
3817
3956
  }
3818
3957
  async _setModel(model, updateGlobalDefaults) {
3819
- 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
+ }
3820
3963
  if (!(await this._modelRuntime.checkAuth(model.provider))) {
3821
3964
  const error = new Error(`No API key for ${model.provider}/${model.id}`);
3822
3965
  this._recordRejectedModelChange(model, error);
3823
3966
  throw error;
3824
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
+ }
3825
3975
  // A manual model change abandons any active fallback window; if a fallback
3826
3976
  // retry sleep is still pending, cancel it so no surprise continuation fires.
3827
3977
  const hadActiveFallback = this._retryFallback.activeState !== undefined;
@@ -3868,6 +4018,11 @@ export class AgentSession {
3868
4018
  }
3869
4019
  const thinking = this._getThinkingForModelSwitch(model, opts.ephemeralThinkingLevel);
3870
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;
3871
4026
  this.agent.state.model = model;
3872
4027
  if (!(model.id === "gpt-6-astra" && (model.provider === "openai" || model.provider === "openai-codex"))) {
3873
4028
  this.agent.state.reasoningBaseline = undefined;
@@ -3877,8 +4032,6 @@ export class AgentSession {
3877
4032
  const previousFastMode = this.isFastModeActive();
3878
4033
  const previousThinkingLevel = this.agent.state.thinkingLevel;
3879
4034
  const previousThinkingSelection = this.agent.state.thinkingSelection;
3880
- const previousReasoningBaseline = this.agent.state.reasoningBaseline;
3881
- const previousAbortServerSideFallback = this.agent.abortServerSideFallback;
3882
4035
  this.agent.abortServerSideFallback =
3883
4036
  this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain();
3884
4037
  this._currentServiceTier = this._resolveServiceTier(model, scopedMatch?.serviceTier);
@@ -3894,12 +4047,35 @@ export class AgentSession {
3894
4047
  const systemPromptChange = opts.emitModelSelect
3895
4048
  ? await this._emitModelSelect(model, previousModel, opts.modelSelectSource)
3896
4049
  : undefined;
3897
- 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);
3898
4069
  if (opts.appendSessionEntry) {
3899
4070
  this.sessionManager.appendModelChange(model.provider, model.id, opts.entryReason, previousModel?.provider, previousModel?.id);
3900
4071
  }
3901
4072
  if (opts.persistDefault)
3902
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;
3903
4079
  // Emit only after all admission hooks have accepted the candidate.
3904
4080
  this._emit({
3905
4081
  type: "model_changed",
@@ -3994,7 +4170,14 @@ export class AgentSession {
3994
4170
  const alternatives = favoriteModels.filter((entry) => !modelsAreEqual(entry.model, currentModel));
3995
4171
  const onlyAlternative = alternatives.length === 1 ? alternatives[0] : undefined;
3996
4172
  if (onlyAlternative) {
3997
- 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
+ }
3998
4181
  }
3999
4182
  return {
4000
4183
  model: currentModel,
@@ -4005,6 +4188,18 @@ export class AgentSession {
4005
4188
  }
4006
4189
  const next = favoriteModels[selectedIndex];
4007
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
+ }
4008
4203
  this._assertModelUsableForSwitch(next.model, liveContextTokens);
4009
4204
  const invalidatesCompaction = this._modelSelectionChangesContext(currentModel, next.model) ||
4010
4205
  currentModel?.provider !== next.model.provider ||
@@ -4029,9 +4224,29 @@ export class AgentSession {
4029
4224
  // default - may be written before this guard accepts, or a refused cycle
4030
4225
  // would resume on a model that never ran (the ordering `_switchActiveModel`
4031
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
+ }
4032
4243
  this._assertModelUsableForSwitch(next.model, liveContextTokens);
4033
4244
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
4034
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;
4035
4250
  // Post-switch, same contract as _switchActiveModel: the level in force AFTER the cycle.
4036
4251
  this._emit({
4037
4252
  type: "model_changed",
@@ -4662,7 +4877,10 @@ export class AgentSession {
4662
4877
  throw new CompactionCancelledError();
4663
4878
  }
4664
4879
  const pathEntries = this.sessionManager.getBranch();
4665
- 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);
4666
4884
  let compactionResult = request.precomputed;
4667
4885
  let fromExtension = request.precomputed !== undefined;
4668
4886
  if (!compactionResult) {
@@ -4989,6 +5207,9 @@ export class AgentSession {
4989
5207
  }
4990
5208
  const settings = this._getCompactionSettings();
4991
5209
  const model = this.model;
5210
+ if (this._pendingModelSwitch !== undefined) {
5211
+ return await this._applyPendingModelSwitch(assistantMessage, skipAbortedCheck, inlineReason);
5212
+ }
4992
5213
  if (this._resumeCompactionRequirement !== undefined) {
4993
5214
  if (!model)
4994
5215
  throw new RequiredCompactionError();
@@ -5305,7 +5526,7 @@ export class AgentSession {
5305
5526
  this._delegatedCompactionKey.provider === model.provider &&
5306
5527
  this._delegatedCompactionKey.id === model.id);
5307
5528
  }
5308
- async _runPrePromptCompaction(lastAssistantMessage, skipAbortedCheck, reason = "pre_prompt", willRetry = false, allowSummaryOnly = false) {
5529
+ async _runPrePromptCompaction(lastAssistantMessage, skipAbortedCheck, reason = "pre_prompt", willRetry = false, allowSummaryOnly = false, keepRecentTokensOverride) {
5309
5530
  if (this._isCompactionDelegated())
5310
5531
  return false;
5311
5532
  const controller = new AbortController();
@@ -5322,6 +5543,7 @@ export class AgentSession {
5322
5543
  lastAssistantMessage,
5323
5544
  skipAbortedCheck,
5324
5545
  allowSummaryOnly,
5546
+ keepRecentTokensOverride,
5325
5547
  });
5326
5548
  if (!execution.accepted &&
5327
5549
  lastAssistantMessage &&