@code-yeongyu/senpi 2026.10.11 → 2026.10.12

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 (188) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/bundle/chunks/anthropic-messages-GYG6YZY5.js +10 -0
  3. package/dist/bundle/chunks/{app-server-command-NHDOL4HQ.js → app-server-command-OL6PPUOA.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-EJF2XJ7Q.js → azure-openai-responses-GU6FOSJR.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-ZABLYCHT.js → chunk-46JEQXD7.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-VE3D74HN.js → chunk-55VKYA5V.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-7UNN3YHW.js → chunk-5AVFHHDF.js} +230 -221
  8. package/dist/bundle/chunks/{chunk-2JE2DOIF.js → chunk-5DPTM5E2.js} +1 -1
  9. package/dist/bundle/chunks/chunk-5UPUSGP5.js +2 -0
  10. package/dist/bundle/chunks/chunk-5VMRMY6N.js +16 -0
  11. package/dist/bundle/chunks/{chunk-7X3VRAM3.js → chunk-7P3QPVTF.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-T6IYKWIH.js → chunk-BUGZZVQW.js} +1 -1
  13. package/dist/bundle/chunks/chunk-DL7EFVT2.js +12 -0
  14. package/dist/bundle/chunks/chunk-E5YUVGLQ.js +11 -0
  15. package/dist/bundle/chunks/{chunk-RLFQICEY.js → chunk-EQJY2B5F.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-VYKBN4MR.js → chunk-GC2RU5TS.js} +1 -1
  17. package/dist/bundle/chunks/chunk-GPKEJOTQ.js +202 -0
  18. package/dist/bundle/chunks/{chunk-KCVSSFOV.js → chunk-GQ5PWY6P.js} +4 -3
  19. package/dist/bundle/chunks/{chunk-4GPJOGXZ.js → chunk-I4AEC6MH.js} +1 -1
  20. package/dist/bundle/chunks/chunk-JXSFGRBE.js +8 -0
  21. package/dist/bundle/chunks/{chunk-YGCMN2CX.js → chunk-NAY5LB53.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-EBOD66AS.js → chunk-PBN2CATL.js} +2 -2
  23. package/dist/bundle/chunks/chunk-PFNBY43W.js +2 -0
  24. package/dist/bundle/chunks/{chunk-CY2B4QRR.js → chunk-PUWEQK2W.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-5DBPUQSX.js → chunk-SF6AUNQZ.js} +1 -1
  26. package/dist/bundle/chunks/{chunk-G2XAU4BX.js → chunk-TMTT5CLA.js} +1 -1
  27. package/dist/bundle/chunks/{chunk-2JKDZXIY.js → chunk-VWFKLW73.js} +2 -2
  28. package/dist/bundle/chunks/chunk-WZMSBRTW.js +2 -0
  29. package/dist/bundle/chunks/{chunk-55HLKZSB.js → chunk-XVJRRLGN.js} +1 -1
  30. package/dist/bundle/chunks/chunk-Z2PWWBBD.js +31 -0
  31. package/dist/bundle/chunks/cli-main-RSIYAR4J.js +3 -0
  32. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-SWVU2AXJ.js → cloudflare-workers-ai-system-one-LVWRKOUD.js} +1 -1
  33. package/dist/bundle/chunks/{google-generative-ai-XG4YX6FQ.js → google-generative-ai-JMGYPTXL.js} +1 -1
  34. package/dist/bundle/chunks/{google-vertex-BBOZ6OUB.js → google-vertex-CI4FPIU7.js} +1 -1
  35. package/dist/bundle/chunks/{help-fast-path-EZ3KEW6V.js → help-fast-path-M7KMBM6K.js} +1 -1
  36. package/dist/bundle/chunks/host-command-6UR3DSFR.js +2 -0
  37. package/dist/bundle/chunks/host-lifecycle-2BKCCK5X.js +3 -0
  38. package/dist/bundle/chunks/host-lifecycle.js +10 -7
  39. package/dist/bundle/chunks/host-session-command-A2MZHEBP.js +19 -0
  40. package/dist/bundle/chunks/interactive-mode-EJBMTJOH.js +2 -0
  41. package/dist/bundle/chunks/{list-tips-WE2YGQM6.js → list-tips-BEQAOA4N.js} +1 -1
  42. package/dist/bundle/chunks/{mistral-conversations-RB3ZBCGE.js → mistral-conversations-5OD7662T.js} +1 -1
  43. package/dist/bundle/chunks/{multi-session-host-V4CUQLV7.js → multi-session-host-YYMLHCUA.js} +4 -4
  44. package/dist/bundle/chunks/{openai-codex-responses-DBTPUXRW.js → openai-codex-responses-FN3UPAFI.js} +1 -1
  45. package/dist/bundle/chunks/{openai-completions-QAJQC4BY.js → openai-completions-KMUQ3T4M.js} +1 -1
  46. package/dist/bundle/chunks/{openai-responses-UBJWCEHG.js → openai-responses-V4Q5GVLO.js} +1 -1
  47. package/dist/bundle/chunks/{package-manager-cli-WTRDT4ZH.js → package-manager-cli-6A5ZP26E.js} +1 -1
  48. package/dist/bundle/chunks/rotation-stream-PT6LLBMJ.js +2 -0
  49. package/dist/bundle/chunks/rpc-mode-XNYJPYUM.js +2 -0
  50. package/dist/bundle/chunks/runtime-session-ZBHFMT65.js +2 -0
  51. package/dist/bundle/chunks/{schedule-command-PJVRKAKD.js → schedule-command-FYQG2MFX.js} +1 -1
  52. package/dist/bundle/chunks/session-control-endpoint-7TX4XHN4.js +3 -0
  53. package/dist/bundle/chunks/session-picker-4RNB27SM.js +2 -0
  54. package/dist/bundle/chunks/session-worker.js +418 -412
  55. package/dist/bundle/chunks/{typesafe-system-one-B5TW3FHM.js → typesafe-system-one-WUHW7HR4.js} +1 -1
  56. package/dist/bundle/cli.js +1 -1
  57. package/dist/bundle/index.js +1 -1
  58. package/dist/bundle/rpc-entry.js +1 -1
  59. package/dist/bundle/runtime-manifest.json +1 -1
  60. package/dist/core/agent-session.d.ts +11 -2
  61. package/dist/core/agent-session.js +152 -43
  62. package/dist/core/compaction/compaction.js +2 -0
  63. package/dist/core/extensions/builtin/anthropic-web-search/index.js +22 -14
  64. package/dist/core/extensions/builtin/cache-keepalive/index.js +8 -4
  65. package/dist/core/extensions/builtin/cache-keepalive/session-prewarm.d.ts +2 -1
  66. package/dist/core/extensions/builtin/cache-keepalive/session-prewarm.js +55 -7
  67. package/dist/core/extensions/builtin/cache-keepalive/startup-activity-gate.d.ts +8 -0
  68. package/dist/core/extensions/builtin/cache-keepalive/startup-activity-gate.js +25 -0
  69. package/dist/core/extensions/builtin/compaction/index.js +13 -5
  70. package/dist/core/extensions/builtin/compaction/speculative.d.ts +1 -0
  71. package/dist/core/extensions/builtin/compaction/speculative.js +8 -4
  72. package/dist/core/extensions/builtin/moved-path-guard/tool-classes.js +1 -0
  73. package/dist/core/extensions/builtin/openai-web-search/index.d.ts +4 -1
  74. package/dist/core/extensions/builtin/openai-web-search/index.js +54 -17
  75. package/dist/core/extensions/builtin/server-search-fallback.d.ts +12 -0
  76. package/dist/core/extensions/builtin/server-search-fallback.js +50 -0
  77. package/dist/core/extensions/builtin/terminal/runtime-session.d.ts +10 -1
  78. package/dist/core/extensions/builtin/terminal/runtime-session.js +48 -4
  79. package/dist/core/extensions/builtin/websearch/index.d.ts +1 -1
  80. package/dist/core/extensions/builtin/websearch/index.js +119 -18
  81. package/dist/core/extensions/builtin/websearch/server-search.d.ts +11 -0
  82. package/dist/core/extensions/builtin/websearch/server-search.js +230 -0
  83. package/dist/core/extensions/builtin/websearch/websearch/native.d.ts +2 -0
  84. package/dist/core/extensions/builtin/websearch/websearch/search-model.js +15 -0
  85. package/dist/core/extensions/runner.d.ts +8 -1
  86. package/dist/core/extensions/runner.js +56 -7
  87. package/dist/core/extensions/types.d.ts +12 -2
  88. package/dist/core/extensions/user-activity.d.ts +10 -0
  89. package/dist/core/extensions/user-activity.js +20 -0
  90. package/dist/core/model-config-schema.d.ts +34 -0
  91. package/dist/core/model-config-schema.js +2 -0
  92. package/dist/core/model-config.js +1 -0
  93. package/dist/core/reserved-keywords.d.ts +28 -0
  94. package/dist/core/reserved-keywords.js +81 -0
  95. package/dist/core/retry-fallback/candidates.d.ts +1 -1
  96. package/dist/core/retry-fallback/candidates.js +1 -12
  97. package/dist/core/retry-fallback/controller.d.ts +3 -1
  98. package/dist/core/retry-fallback/controller.js +11 -5
  99. package/dist/core/retry-fallback/cooldown.d.ts +2 -1
  100. package/dist/core/retry-fallback/cooldown.js +21 -12
  101. package/dist/core/retry-fallback/unusable.d.ts +2 -0
  102. package/dist/core/retry-fallback/unusable.js +14 -2
  103. package/dist/core/retry-fallback/usage-limit.d.ts +10 -1
  104. package/dist/core/retry-fallback/usage-limit.js +39 -2
  105. package/dist/core/session-title-generator.js +2 -0
  106. package/dist/index.d.ts +2 -1
  107. package/dist/index.js +1 -0
  108. package/dist/modes/index.d.ts +1 -1
  109. package/dist/modes/interactive/components/assistant-render-descriptors.js +7 -1
  110. package/dist/modes/interactive/composer-user-activity.d.ts +6 -0
  111. package/dist/modes/interactive/composer-user-activity.js +30 -0
  112. package/dist/modes/interactive/grok/chrome.js +2 -1
  113. package/dist/modes/interactive/interactive-mode.js +15 -6
  114. package/dist/modes/interactive/session-control-registry.js +8 -1
  115. package/dist/modes/interactive/theme/reserved-keyword-rainbow.d.ts +11 -0
  116. package/dist/modes/interactive/theme/reserved-keyword-rainbow.js +50 -0
  117. package/dist/modes/interactive/theme/theme.d.ts +3 -1
  118. package/dist/modes/interactive/theme/theme.js +9 -0
  119. package/dist/modes/print-mode.js +7 -2
  120. package/dist/modes/rpc/connection-handler.js +43 -11
  121. package/dist/modes/rpc/custom-capability.d.ts +2 -0
  122. package/dist/modes/rpc/custom-capability.js +2 -0
  123. package/dist/modes/rpc/host-admission-proxy.d.ts +9 -0
  124. package/dist/modes/rpc/host-admission-proxy.js +211 -0
  125. package/dist/modes/rpc/host-admission.d.ts +21 -0
  126. package/dist/modes/rpc/host-admission.js +59 -0
  127. package/dist/modes/rpc/host-capabilities.js +2 -1
  128. package/dist/modes/rpc/host-client-occupancy.d.ts +2 -1
  129. package/dist/modes/rpc/host-client-occupancy.js +7 -0
  130. package/dist/modes/rpc/host-decision.d.ts +2 -2
  131. package/dist/modes/rpc/host-decision.js +4 -0
  132. package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
  133. package/dist/modes/rpc/host-ensure.js +31 -3
  134. package/dist/modes/rpc/host-lifecycle-drain.d.ts +2 -1
  135. package/dist/modes/rpc/host-lifecycle-drain.js +3 -1
  136. package/dist/modes/rpc/host-lifecycle-proxy.d.ts +3 -2
  137. package/dist/modes/rpc/host-lifecycle-proxy.js +10 -19
  138. package/dist/modes/rpc/host-lifecycle-shutdown.d.ts +2 -0
  139. package/dist/modes/rpc/host-lifecycle-shutdown.js +3 -1
  140. package/dist/modes/rpc/host-lifecycle-ticker.d.ts +11 -0
  141. package/dist/modes/rpc/host-lifecycle-ticker.js +27 -0
  142. package/dist/modes/rpc/host-lifecycle.js +15 -23
  143. package/dist/modes/rpc/host-outcome.js +1 -0
  144. package/dist/modes/rpc/host-protocol-info.d.ts +2 -0
  145. package/dist/modes/rpc/host-protocol-info.js +1 -0
  146. package/dist/modes/rpc/host-session-runner.js +22 -1
  147. package/dist/modes/rpc/host-status-all.js +1 -0
  148. package/dist/modes/rpc/host-status.d.ts +1 -0
  149. package/dist/modes/rpc/host-status.js +5 -1
  150. package/dist/modes/rpc/rpc-client.d.ts +7 -1
  151. package/dist/modes/rpc/rpc-client.js +12 -0
  152. package/dist/modes/rpc/rpc-types.d.ts +49 -1
  153. package/dist/modes/rpc/rpc-types.js +5 -0
  154. package/dist/modes/rpc/session-command-activity.js +2 -0
  155. package/dist/modes/rpc/session-held.js +3 -0
  156. package/docs/extensions.md +13 -1
  157. package/docs/providers.md +88 -0
  158. package/docs/rpc.md +150 -5
  159. package/docs/web-search.md +3 -1
  160. package/package.json +7 -7
  161. package/dist/bundle/chunks/anthropic-messages-5QQJVQPZ.js +0 -9
  162. package/dist/bundle/chunks/chunk-2A3MS2Z7.js +0 -8
  163. package/dist/bundle/chunks/chunk-6SBY4ZWC.js +0 -2
  164. package/dist/bundle/chunks/chunk-6WCOSBI7.js +0 -2
  165. package/dist/bundle/chunks/chunk-E75RO54O.js +0 -14
  166. package/dist/bundle/chunks/chunk-JMYO34RK.js +0 -2
  167. package/dist/bundle/chunks/chunk-KRNXUTZJ.js +0 -2
  168. package/dist/bundle/chunks/chunk-L4HVJWIF.js +0 -11
  169. package/dist/bundle/chunks/chunk-N5WLLHUE.js +0 -2
  170. package/dist/bundle/chunks/chunk-NEFNNQSV.js +0 -9
  171. package/dist/bundle/chunks/chunk-PWIYVLIA.js +0 -9
  172. package/dist/bundle/chunks/chunk-Q3R44CGT.js +0 -5
  173. package/dist/bundle/chunks/chunk-QIARZN7G.js +0 -31
  174. package/dist/bundle/chunks/chunk-QKU6JOA4.js +0 -2
  175. package/dist/bundle/chunks/chunk-QYWXDE47.js +0 -2
  176. package/dist/bundle/chunks/chunk-XHELSLN3.js +0 -188
  177. package/dist/bundle/chunks/chunk-YDJ4HGWO.js +0 -16
  178. package/dist/bundle/chunks/chunk-ZJHRDQCH.js +0 -2
  179. package/dist/bundle/chunks/cli-main-TUBNBZNK.js +0 -3
  180. package/dist/bundle/chunks/host-command-WO7VKJBJ.js +0 -2
  181. package/dist/bundle/chunks/host-lifecycle-X2J5FTJL.js +0 -3
  182. package/dist/bundle/chunks/host-session-command-ICBOH5RP.js +0 -19
  183. package/dist/bundle/chunks/interactive-mode-UGDNCGQ5.js +0 -2
  184. package/dist/bundle/chunks/rotation-stream-OODURUGZ.js +0 -2
  185. package/dist/bundle/chunks/rpc-mode-22MCT32O.js +0 -2
  186. package/dist/bundle/chunks/runtime-session-I4NDNGC5.js +0 -2
  187. package/dist/bundle/chunks/session-control-endpoint-SFPRQBDO.js +0 -3
  188. package/dist/bundle/chunks/session-picker-3ZQPV7Z3.js +0 -2
@@ -73,6 +73,7 @@ import { ExecuteToolError, ExtensionRunner, wrapRegisteredTools, } from "./exten
73
73
  import { projectKernelPrelude } from "./extensions/kernel-prelude.js";
74
74
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
75
75
  import { normalizeToolExposure, RUNTIME_EXTENSION_PATH } from "./extensions/types.js";
76
+ import { createUserActivitySource, emitUserActivity } from "./extensions/user-activity.js";
76
77
  import { deliveryIdOf, ExternalAdmission } from "./external-admission.js";
77
78
  import { shouldWarnHighReasoning } from "./high-reasoning-warning.js";
78
79
  import { LazyToolActivation } from "./lazy-tool-activation.js";
@@ -98,12 +99,13 @@ import { SelectorCooldowns } from "./retry-fallback/cooldown.js";
98
99
  import { classifyRateLimitedWait, degradeWithoutFallback, nextInTurnDelayMs, probeBackSchedule, } from "./retry-fallback/hint-policy.js";
99
100
  import { createFallbackLogger } from "./retry-fallback/log.js";
100
101
  import { ProbeBackScheduler } from "./retry-fallback/probe-scheduler.js";
102
+ import { AccountUsageLimitError, describeUsageLimitEntry, describeUsageLimitMessage, usageLimitScope, } from "./retry-fallback/usage-limit.js";
101
103
  import { validateFallbackChains } from "./retry-fallback/validate.js";
102
104
  import { isSessionBusySnapshot, WakeSourceTracker } from "./session-activity.js";
103
105
  import { createSessionControlActions } from "./session-control-actions.js";
104
106
  import { computeSessionFailureReport } from "./session-failure-report.js";
105
107
  import { createSessionLogger } from "./session-log.js";
106
- import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, SessionManager, } from "./session-manager.js";
108
+ import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, getSessionContextEntryId, SessionManager, } from "./session-manager.js";
107
109
  import { generateSessionTitle, sessionTitleRetryPolicy, shouldSkipSessionTitle } from "./session-title-generator.js";
108
110
  import { SessionWorkBarrier } from "./session-work-barrier.js";
109
111
  import { DEFAULT_STREAM_START_TIMEOUT_MS, DEFAULT_TOOL_NAMES, } from "./settings-manager.js";
@@ -423,6 +425,7 @@ export class AgentSession {
423
425
  // (which rebuilds the base prompt with discovered skills) have run.
424
426
  this._sessionStartSettled = Promise.resolve();
425
427
  this._promptCachePrefixBuilds = new PromptCachePrefixBuilds();
428
+ this._userActivity = createUserActivitySource();
426
429
  this._extensionMode = "print";
427
430
  this._providerRequiredNoticed = false;
428
431
  this._probeBackLaneSeq = 0;
@@ -571,7 +574,7 @@ export class AgentSession {
571
574
  // Cooldowns, probe schedules, and circuits measure elapsed time, so they all
572
575
  // run on the monotonic clock; a wall-clock jump never parks or releases an entry.
573
576
  this._fallbackNow = config.fallbackNow ?? monotonicNow;
574
- this._selectorCooldowns = new SelectorCooldowns(this._fallbackNow);
577
+ this._selectorCooldowns = new SelectorCooldowns(this._fallbackNow, undefined, config.fallbackWallNow ?? config.fallbackNow ?? Date.now);
575
578
  const lease = () => this._fallbackCircuitsLease;
576
579
  this._fallbackCircuits = createFallbackCircuitAccess({
577
580
  // Read on every use: the hold is taken as construction's last step.
@@ -742,19 +745,35 @@ export class AgentSession {
742
745
  * `compaction.model` override ("provider/model"), that model is used for the
743
746
  * summarization call instead of the session model — this is what lets an
744
747
  * SDK-owned lane (anthropic-subscription) compact on a cheaper/different model.
745
- * Any resolution failure (unset, malformed, unknown model) falls back to the
746
- * session model so compaction never silently breaks.
748
+ * Unset, malformed, or unknown overrides keep a usable session model.
749
+ * A spent account instead uses the held target or another authenticated model.
747
750
  */
748
751
  _resolveCompactionModel(sessionModel) {
749
752
  const override = this._getCompactionSettings().model;
750
- if (!override)
751
- return sessionModel;
752
- const slash = override.indexOf("/");
753
- if (slash <= 0 || slash === override.length - 1)
754
- return sessionModel;
755
- const provider = override.slice(0, slash);
756
- const modelId = override.slice(slash + 1);
757
- return this._modelRuntime.getModel(provider, modelId) ?? sessionModel;
753
+ const slash = override?.indexOf("/") ?? -1;
754
+ const configured = override && slash > 0
755
+ ? this._modelRuntime.getModel(override.slice(0, slash), override.slice(slash + 1))
756
+ : undefined;
757
+ const usable = (model) => !this._retryFallback.isProviderSpent(model.provider);
758
+ // Preserve the explicit override and original-model geometry on healthy sessions.
759
+ // A held downswitch uses its target only when the original account cannot serve.
760
+ if (usable(sessionModel))
761
+ return configured && usable(configured) ? configured : sessionModel;
762
+ const pending = this._pendingModelSwitch?.model;
763
+ if (pending && usable(pending))
764
+ return pending;
765
+ if (configured && usable(configured))
766
+ return configured;
767
+ const requiredContextWindow = estimateMessagesTokens(filterContextExcludedMessages(this._runtimeMessages())) +
768
+ this._getCompactionSettings().reserveTokens;
769
+ const available = this._modelRuntime
770
+ .getModels()
771
+ .find((model) => usable(model) &&
772
+ model.contextWindow >= requiredContextWindow &&
773
+ this._modelRuntime.hasConfiguredAuth(model.provider));
774
+ if (available)
775
+ return available;
776
+ throw new AccountUsageLimitError(sessionModel.provider, this._retryFallback.accountError(sessionModel.provider));
758
777
  }
759
778
  async _getCompactionRequestAuth(model, signal, callbacks) {
760
779
  const auth = await this._getSummarizationRequestAuth(model, signal, callbacks);
@@ -1365,6 +1384,39 @@ export class AgentSession {
1365
1384
  }
1366
1385
  _emit(event) {
1367
1386
  this._logSessionEvent(event);
1387
+ if (event.type === "entry_appended") {
1388
+ event = { ...event, entry: describeUsageLimitEntry(event.entry) };
1389
+ }
1390
+ if (event.type === "retry_fallback_exhausted" && usageLimitScope(event.lastError) === "account") {
1391
+ event = {
1392
+ ...event,
1393
+ lastError: new AccountUsageLimitError(this.model?.provider ?? "", event.lastError).message,
1394
+ };
1395
+ }
1396
+ if (event.type === "agent_end") {
1397
+ event = {
1398
+ ...event,
1399
+ messages: event.messages.map((message) => message.role === "assistant" ? describeUsageLimitMessage(message) : message),
1400
+ };
1401
+ }
1402
+ // Keep provider text on the engine message for classification and in session.log,
1403
+ // but publish only the existing user-facing description to clients.
1404
+ if ((event.type === "message_end" || event.type === "message_start" || event.type === "message_update") &&
1405
+ event.message.role === "assistant") {
1406
+ const message = describeUsageLimitMessage(event.message);
1407
+ event =
1408
+ event.type === "message_update"
1409
+ ? {
1410
+ ...event,
1411
+ message,
1412
+ assistantMessageEvent: {
1413
+ ...event.assistantMessageEvent,
1414
+ ...("error" in event.assistantMessageEvent ? { error: message } : {}),
1415
+ ...("partial" in event.assistantMessageEvent ? { partial: message } : {}),
1416
+ },
1417
+ }
1418
+ : { ...event, message };
1419
+ }
1368
1420
  for (const l of [...this._eventListeners]) {
1369
1421
  l(event);
1370
1422
  }
@@ -2213,7 +2265,9 @@ export class AgentSession {
2213
2265
  this._clearCircuitProbeWatchdog();
2214
2266
  // Billing and quota exhaustion make the entry unusable for every session,
2215
2267
  // whether or not the chain has a candidate left.
2216
- if (msg.stopReason === "error" && isHealthExhaustionFailure(msg.errorMessage) && this.model) {
2268
+ if (msg.stopReason === "error" &&
2269
+ (isHealthExhaustionFailure(msg.errorMessage) || usageLimitScope(msg.errorMessage) === "account") &&
2270
+ this.model) {
2217
2271
  this._retryFallback.noteHealthFailure(this.model, this.thinkingLevel, { errorMessage: msg.errorMessage });
2218
2272
  }
2219
2273
  const requiredAutoCompaction = this._getRequiredAutoCompactionReason(msg);
@@ -3275,6 +3329,10 @@ export class AgentSession {
3275
3329
  onExtensionEvent(channel, handler) {
3276
3330
  return this._resourceLoader.onExtensionEvent?.(channel, handler) ?? (() => { });
3277
3331
  }
3332
+ /** Host input entry point; not exposed by the extension API or its event bus. */
3333
+ recordUserActivity(kind) {
3334
+ this._userActivity.emit(kind);
3335
+ }
3278
3336
  /** Publish on the internal event bus shared by this session's extensions. */
3279
3337
  emitExtensionEvent(channel, data) {
3280
3338
  this._resourceLoader.emitExtensionEvent?.(channel, data);
@@ -3467,6 +3525,8 @@ export class AgentSession {
3467
3525
  submittedByCommand: options?.source === "extension",
3468
3526
  });
3469
3527
  try {
3528
+ if (options?.source !== "extension")
3529
+ emitUserActivity(this, "prompt");
3470
3530
  await this._prompt(text, {
3471
3531
  ...options,
3472
3532
  promptDisposition: (disposition) => {
@@ -3573,6 +3633,15 @@ export class AgentSession {
3573
3633
  if (!this.isStreaming) {
3574
3634
  try {
3575
3635
  await this._maybeRestoreFallbackPrimary();
3636
+ const model = this.model;
3637
+ if (model && !this._pendingModelSwitch && this._retryFallback.isProviderSpent(model.provider)) {
3638
+ this._retryFallback.resetTurn();
3639
+ const errorMessage = this._retryFallback.accountError(model.provider);
3640
+ // Routing around a remembered failure did not observe another provider failure.
3641
+ if (!(await this._retryFallback.tryFallback("transient", { errorMessage }, false))) {
3642
+ throw new AccountUsageLimitError(model.provider, errorMessage);
3643
+ }
3644
+ }
3576
3645
  }
3577
3646
  catch (error) {
3578
3647
  if (ownsPromptStart)
@@ -4054,6 +4123,8 @@ export class AgentSession {
4054
4123
  * `input` handlers, skill/template expansion, then the recovery-ordered enqueue.
4055
4124
  */
4056
4125
  async _queueUserInput(text, images, behavior, options) {
4126
+ if (options?.source !== "extension")
4127
+ emitUserActivity(this, "prompt");
4057
4128
  // Check for extension commands (cannot be queued)
4058
4129
  if (text.startsWith("/")) {
4059
4130
  this._throwIfExtensionCommand(text);
@@ -4880,7 +4951,9 @@ export class AgentSession {
4880
4951
  }
4881
4952
  /** Whether a recorded resume requirement still blocks: the current context does not fit the current model. */
4882
4953
  _resumeRequirementStillBlocks(model, settings) {
4883
- const currentContext = estimateContextTokens(filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages)).tokens;
4954
+ // Kept assistant usage predates an accepted checkpoint. The projection
4955
+ // estimates its summary and suffix until a fresh provider response supersedes it.
4956
+ const currentContext = estimateProjectedContextTokens(this.sessionManager.buildSessionProjection(), this.sessionManager.getBranch()).tokens;
4884
4957
  return !projectModelUsabilityBudget({
4885
4958
  model,
4886
4959
  systemPrompt: this.agent.state.systemPrompt,
@@ -6015,6 +6088,7 @@ export class AgentSession {
6015
6088
  }
6016
6089
  throw new Error("Nothing to compact (session too small)");
6017
6090
  }
6091
+ const compactionModel = this._resolveCompactionModel(model);
6018
6092
  if (this._extensionRunner.hasHandlers("session_before_compact")) {
6019
6093
  const messagesBeforeExtension = new Set(this.agent.state.messages);
6020
6094
  const extensionResult = (await this._extensionRunner.emit({
@@ -6050,7 +6124,6 @@ export class AgentSession {
6050
6124
  // (lifecycle record, preparation, branch) still tracks the session
6051
6125
  // model. Falls back to the session model when the override is unset
6052
6126
  // or cannot be resolved.
6053
- const compactionModel = this._resolveCompactionModel(model);
6054
6127
  const { model: requestModel, apiKey, headers, extraBody, env, thinkingLevel, } = await this._getCompactionRequestAuth(compactionModel, signal, this._summarizationRetryCallbacks({ source: "compaction", reason: request.reason }));
6055
6128
  compactionResult = await this._runDefaultCompaction(preparation, requestModel, apiKey, headers, extraBody, request.customInstructions, signal, env, request.reason, thinkingLevel);
6056
6129
  }
@@ -6371,13 +6444,13 @@ export class AgentSession {
6371
6444
  }
6372
6445
  this._resumeCompactionRequirement = undefined;
6373
6446
  }
6374
- const contextTokens = estimateContextTokens(filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages)).tokens;
6375
6447
  const compacted = assistantMessage
6376
6448
  ? await this._checkCompaction(assistantMessage, skipAbortedCheck, inlineReason, retryAfterCompaction)
6377
6449
  : false;
6378
6450
  if (compacted || (assistantMessage && this._postCompactionUsageExemptAssistants.has(assistantMessage))) {
6379
6451
  return compacted;
6380
6452
  }
6453
+ const contextTokens = this._estimatePrePromptContextTokens();
6381
6454
  // Under a virtual selection, the physical model of the latest response supplies the limits;
6382
6455
  // before one, the virtual model's declared limits apply. An undeclared window (<= 0) is unknown, so
6383
6456
  // admission never refuses a turn as "already over the threshold" (#2677). Compaction after a turn
@@ -6389,13 +6462,7 @@ export class AgentSession {
6389
6462
  !shouldCompact(contextTokens, limitsModel.contextWindow, settings)) {
6390
6463
  return false;
6391
6464
  }
6392
- const latestCompaction = getLatestCompactionEntry(this.sessionManager.getBranch());
6393
6465
  const assistantBeforeLatestCompaction = assistantMessage !== undefined && this._isAssistantFromBeforeLatestCompaction(assistantMessage);
6394
- const hasPostCompactionCustomState = latestCompaction !== null &&
6395
- this.agent.state.messages.some((message) => message.role === "custom" && message.timestamp >= new Date(latestCompaction.timestamp).getTime());
6396
- if (assistantBeforeLatestCompaction && !hasPostCompactionCustomState) {
6397
- return false;
6398
- }
6399
6466
  if (assistantBeforeLatestCompaction && assistantMessage) {
6400
6467
  const compacted = await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason);
6401
6468
  if (compacted)
@@ -6406,6 +6473,32 @@ export class AgentSession {
6406
6473
  }
6407
6474
  throw new RequiredCompactionError();
6408
6475
  }
6476
+ /** Share resume's branch provenance rule without projecting trusted usage on every request. */
6477
+ _estimatePrePromptContextTokens(additions = []) {
6478
+ const branch = this.sessionManager.getBranch();
6479
+ const messages = [...this.sessionManager.buildSessionContext().messages, ...additions];
6480
+ const estimate = estimateContextTokens(messages);
6481
+ const usageMessage = estimate.lastUsageIndex === null ? undefined : messages[estimate.lastUsageIndex];
6482
+ const usageEntryId = usageMessage ? getSessionContextEntryId(usageMessage) : undefined;
6483
+ let needsProjection = false;
6484
+ for (let i = branch.length - 1; i >= 0; i--) {
6485
+ const entry = branch[i];
6486
+ if (usageEntryId !== undefined && entry.id === usageEntryId)
6487
+ break;
6488
+ if (entry.type === "compaction" || entry.type === "context_edit") {
6489
+ needsProjection = true;
6490
+ break;
6491
+ }
6492
+ }
6493
+ if (!needsProjection)
6494
+ return estimate.tokens;
6495
+ if (this._prePromptProjection?.branch !== branch) {
6496
+ this._prePromptProjection = { branch, projection: this.sessionManager.buildSessionProjection() };
6497
+ }
6498
+ const { projection } = this._prePromptProjection;
6499
+ return estimateProjectedContextTokens({ ...projection, messages: [...projection.messages, ...additions] }, branch)
6500
+ .tokens;
6501
+ }
6409
6502
  /**
6410
6503
  * Pending queued input the provider will also carry on this turn. Steering and
6411
6504
  * follow-up text enqueued after the admission projection was assembled - by a
@@ -6448,21 +6541,12 @@ export class AgentSession {
6448
6541
  const settings = this._getCompactionSettings();
6449
6542
  const reserveTokens = resolveEffectiveReserveTokens(model.contextWindow, settings);
6450
6543
  const isOversized = () => {
6451
- const providerMessages = filterContextExcludedMessages([
6452
- ...this.agent.state.messages,
6544
+ const contextTokens = this._estimatePrePromptContextTokens([
6453
6545
  ...messages,
6454
6546
  // Re-read the queues on every sample: this closure is evaluated again
6455
6547
  // after compaction, when more input may have arrived.
6456
6548
  ...this._pendingQueuedInputMessages(),
6457
6549
  ]);
6458
- const estimate = estimateContextTokens(providerMessages);
6459
- const usageMessage = estimate.lastUsageIndex === null ? undefined : providerMessages[estimate.lastUsageIndex];
6460
- // Kept assistant usage can describe the pre-compaction request. Once a
6461
- // compaction boundary exists, fall back to byte-derived message estimates
6462
- // until a provider response refreshes that usage.
6463
- const contextTokens = usageMessage?.role === "assistant" && this._isAssistantFromBeforeLatestCompaction(usageMessage)
6464
- ? estimateMessagesTokens(providerMessages)
6465
- : estimate.tokens;
6466
6550
  return contextTokens > model.contextWindow - reserveTokens;
6467
6551
  };
6468
6552
  if (!settings.enabled || !isOversized())
@@ -6638,8 +6722,7 @@ export class AgentSession {
6638
6722
  let contextTokens;
6639
6723
  let staleUsageContentTokens;
6640
6724
  if (inlineReason) {
6641
- const messages = filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages);
6642
- contextTokens = estimateContextTokens(messages).tokens;
6725
+ contextTokens = this._estimatePrePromptContextTokens();
6643
6726
  }
6644
6727
  else {
6645
6728
  const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
@@ -6786,12 +6869,7 @@ export class AgentSession {
6786
6869
  const settings = this._getCompactionSettings();
6787
6870
  if (!model || !settings.enabled)
6788
6871
  return;
6789
- const canonicalMessages = filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages);
6790
- const estimate = estimateContextTokens(canonicalMessages);
6791
- const usageMessage = estimate.lastUsageIndex === null ? undefined : canonicalMessages[estimate.lastUsageIndex];
6792
- const contextTokens = usageMessage?.role === "assistant" && this._isAssistantFromBeforeLatestCompaction(usageMessage)
6793
- ? estimateMessagesTokens(canonicalMessages)
6794
- : estimate.tokens;
6872
+ const contextTokens = this._estimatePrePromptContextTokens();
6795
6873
  // An explicit user prompt passes the proactive policy in the compaction
6796
6874
  // extension's before_agent_start, which automatic continuations never emit.
6797
6875
  // Sampling the same predicate here makes both routes compact at the same
@@ -7379,9 +7457,13 @@ export class AgentSession {
7379
7457
  },
7380
7458
  getContextUsage: () => this.getContextUsage(),
7381
7459
  getCompactionSettings: () => this._getCompactionSettings(),
7460
+ getCompactionModel: () => this.model && this._retryFallback.isProviderSpent(this.model.provider)
7461
+ ? this._resolveCompactionModel(this.model)
7462
+ : this.model,
7382
7463
  getPromptCacheSafeWaitSeconds: () => this.resolvePromptCacheSafeWaitSeconds(),
7383
7464
  getPromptCacheGoalBackstopMaxSeconds: () => this.settingsManager.getPromptCacheGoalBackstopMaxSeconds(),
7384
7465
  getPromptCacheKeepAliveSettings: () => this.settingsManager.getPromptCacheKeepAliveSettings(),
7466
+ onUserActivity: (listener) => this._userActivity.subscribe(listener),
7385
7467
  getLookAtSettings: () => {
7386
7468
  const global = this.settingsManager.getGlobalSettings().lookAt;
7387
7469
  const project = this.settingsManager.getProjectSettings().lookAt;
@@ -8018,6 +8100,7 @@ export class AgentSession {
8018
8100
  return (describeProviderFailureForUser(message.errorMessage, {
8019
8101
  attempts,
8020
8102
  model,
8103
+ usageLimit: usageLimitScope(message.errorMessage),
8021
8104
  recovery: this._retryFallback.hasConfiguredChain() ? "chain-exhausted" : "no-fallback-configured",
8022
8105
  }) ?? (message.errorMessage === undefined ? undefined : stripTurnRetrySuppressionPrefix(message.errorMessage)));
8023
8106
  }
@@ -8109,7 +8192,31 @@ export class AgentSession {
8109
8192
  throw error;
8110
8193
  }
8111
8194
  };
8112
- if (sameModelRemint) {
8195
+ if (usageLimitScope(errorMessage) === "account") {
8196
+ switchedFallback = await tryFallback(isBillingErrorMessage(errorMessage) ? "billing" : "transient", {
8197
+ errorMessage,
8198
+ retryAfterMs: this._getProviderRetryDelayMs(errorMessage),
8199
+ });
8200
+ if (!switchedFallback) {
8201
+ const chainKey = this._retryFallback.exhaustedChainKey;
8202
+ if (chainKey)
8203
+ this._emit({ type: "retry_fallback_exhausted", chainKey, lastError: errorMessage });
8204
+ if (this._retryAttempt > 0) {
8205
+ this._emit({
8206
+ type: "auto_retry_end",
8207
+ success: false,
8208
+ attempt: this._retryAttempt,
8209
+ finalError: this._terminalFailureText(message, this._retryAttempt),
8210
+ });
8211
+ }
8212
+ this._retryAttempt = 0;
8213
+ this._resetHintTierState();
8214
+ this._resolveRetry();
8215
+ return "not-handled";
8216
+ }
8217
+ this._retryAttempt = 1;
8218
+ }
8219
+ else if (sameModelRemint) {
8113
8220
  this._retryAttempt++;
8114
8221
  if (this._retryAttempt > retryProfile.turn.maxRetries) {
8115
8222
  if (this._retryAttempt > 1) {
@@ -8498,7 +8605,9 @@ export class AgentSession {
8498
8605
  attempt: this._retryAttempt,
8499
8606
  maxAttempts: retryProfile.turn.maxRetries,
8500
8607
  delayMs,
8501
- errorMessage,
8608
+ errorMessage: usageLimitScope(errorMessage) === "account"
8609
+ ? new AccountUsageLimitError(message.provider, errorMessage).message
8610
+ : errorMessage,
8502
8611
  });
8503
8612
  // Remove error message from agent state (keep in session for history)
8504
8613
  const messages = this.agent.state.messages;
@@ -8535,7 +8644,7 @@ export class AgentSession {
8535
8644
  // before the continuation so a rejected compaction never admits that model.
8536
8645
  const model = this.model;
8537
8646
  const compactionSettings = this._getCompactionSettings();
8538
- const contextTokens = estimateContextTokens(filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages)).tokens;
8647
+ const contextTokens = this._estimatePrePromptContextTokens();
8539
8648
  if (compactionSettings.enabled &&
8540
8649
  model &&
8541
8650
  shouldCompact(contextTokens, model.contextWindow, compactionSettings)) {
@@ -8,6 +8,7 @@ import { dropFailedAssistantTurns, getCurrentSystemMessage, normalizeContext, re
8
8
  import { streamSimple } from "@earendil-works/pi-ai/compat";
9
9
  import { estimateContextTokens as estimateProviderContextTokens } from "@earendil-works/pi-ai/utils/estimate";
10
10
  import { resolveEffectiveReserveTokens } from "../extensions/builtin/compaction/policy.js";
11
+ import { withoutServerSearch } from "../extensions/builtin/server-search-fallback.js";
11
12
  import { convertToLlm, isContextExcludedCustomMessage } from "../messages.js";
12
13
  import { buildSessionProjection, sessionEntryToContextMessages, } from "../session-manager.js";
13
14
  import { estimateCacheKey, isTransientMessage, serializeForEstimate } from "./estimate-cache-key.js";
@@ -629,6 +630,7 @@ export async function completeSummarization(model, context, options, streamFn, r
629
630
  // opt into short retention for an exact provider-context prefix.
630
631
  const isolatedOptions = {
631
632
  ...options,
633
+ onPayload: withoutServerSearch(options.onPayload, options.cacheRetention),
632
634
  cacheRetention: options.cacheRetention ?? "none",
633
635
  affinitySessionId: options.affinitySessionId ?? options.sessionId,
634
636
  sessionId: uuidv7(),
@@ -1,3 +1,4 @@
1
+ import { hasServerSearchFailed, isServerSearchDisabled, isServerSearchFallback, markServerSearchFallback, } from "../server-search-fallback.js";
1
2
  const WEB_SEARCH_MAX_USES = 8;
2
3
  const ENABLE_ENV = "PI_ANTHROPIC_WEB_SEARCH";
3
4
  const ALLOWED_DOMAINS_ENV = "PI_ANTHROPIC_WEB_SEARCH_ALLOWED_DOMAINS";
@@ -38,25 +39,26 @@ function resolveTarget(target) {
38
39
  }
39
40
  return target;
40
41
  }
41
- function isNativeAnthropicEndpoint(model) {
42
- try {
43
- return new URL(model.baseUrl).hostname === "api.anthropic.com";
44
- }
45
- catch {
46
- return false;
47
- }
48
- }
49
- // Mirrors pi-ai's `AnthropicMessagesCompat.supportsWebSearch` default:
50
- // first-party api.anthropic.com only. Anthropic-compatible endpoints (e.g.,
51
- // kimi-coding) may execute the server-side search but reject the replayed
52
- // server_tool_use / web_search_tool_result blocks on the next request.
42
+ // https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
43
+ // supportsWebSearch means BOTH main-request search and unchanged encrypted-result
44
+ // replay. Preserve main's first-party API/Claude OAuth default; gateways opt in.
45
+ // ccapi's Messages route passed a real search + replay for senpi#3122; opt its
46
+ // provider/model entries in with compat.supportsWebSearch: true. Never infer that
47
+ // compatibility from a proxy hostname. Other compatible routes remain explicit.
53
48
  export function supportsNativeAnthropicWebSearch(target) {
54
49
  const model = resolveTarget(target);
55
50
  if (model?.api !== "anthropic-messages") {
56
51
  return false;
57
52
  }
58
53
  const compat = model.compat;
59
- return compat?.supportsWebSearch ?? isNativeAnthropicEndpoint(model);
54
+ if (compat?.supportsWebSearch !== undefined)
55
+ return compat.supportsWebSearch;
56
+ try {
57
+ return new URL(model.baseUrl).hostname === "api.anthropic.com";
58
+ }
59
+ catch {
60
+ return false;
61
+ }
60
62
  }
61
63
  function parseDomainListEnv(envVar) {
62
64
  const envValue = process.env[envVar];
@@ -132,6 +134,8 @@ export function addAnthropicWebSearchToPayload(target, payload) {
132
134
  if (!isRecord(payload)) {
133
135
  return payload;
134
136
  }
137
+ if (isServerSearchDisabled(payload) || isServerSearchFallback(payload))
138
+ return stripNativeAnthropicWebSearch(payload);
135
139
  if (!supportsNativeAnthropicWebSearch(model)) {
136
140
  // Anthropic-compatible endpoints (e.g., kimi-coding) may execute the
137
141
  // server-side search but reject the replayed server_tool_use /
@@ -175,7 +179,11 @@ Prefer web_search over guessing when freshness matters.
175
179
  `;
176
180
  export default function anthropicWebSearchExtension(pi) {
177
181
  pi.on("before_provider_request", (event, ctx) => {
178
- return addAnthropicWebSearchToPayload(ctx.model, event.payload);
182
+ const model = event.model ?? ctx.model;
183
+ const payload = hasServerSearchFailed(ctx.sessionManager, model)
184
+ ? markServerSearchFallback(event.payload)
185
+ : event.payload;
186
+ return addAnthropicWebSearchToPayload(model, payload);
179
187
  });
180
188
  pi.on("session_start", async (_event, ctx) => {
181
189
  syncUi(ctx);
@@ -3,6 +3,7 @@ import { convertToLlm, filterContextExcludedMessages } from "../../../messages.j
3
3
  import { noticeEntryRenderer } from "../../notice/index.js";
4
4
  import { formatWarmTokenCount } from "../goal/cache-warm.js";
5
5
  import { createSessionPrewarm } from "./session-prewarm.js";
6
+ import { createStartupActivityGate } from "./startup-activity-gate.js";
6
7
  export const CACHE_KEEPALIVE_ENTRY_TYPE = "cache-keepalive";
7
8
  export const CACHE_WARM_PING_EVENT = "cache_warm_ping";
8
9
  export const renderCacheKeepAliveEntry = noticeEntryRenderer((entry) => {
@@ -20,6 +21,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
20
21
  const warm = dependencies.warmPromptCache ?? warmPromptCache;
21
22
  return (pi) => {
22
23
  const prewarm = createSessionPrewarm(pi, { warm, isPrewarmModel: dependencies.isPromptCachePrewarmModel });
24
+ const startupActivity = createStartupActivityGate();
23
25
  let ctx;
24
26
  let timer;
25
27
  let inFlight = false;
@@ -57,7 +59,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
57
59
  }
58
60
  }
59
61
  function arm() {
60
- if (parked || timer !== undefined || inFlight)
62
+ if (startupActivity.waiting || parked || timer !== undefined || inFlight)
61
63
  return;
62
64
  const current = ctx;
63
65
  const settings = current?.getPromptCacheKeepAliveSettings?.();
@@ -119,7 +121,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
119
121
  const iteration = attempts;
120
122
  const pingGeneration = generation;
121
123
  try {
122
- const preparation = await current.prepareProviderRequest?.(lastMessages);
124
+ const preparation = await current.prepareProviderRequest?.(lastMessages, { allowServerSearch: true });
123
125
  if (pingGeneration !== generation) {
124
126
  inFlight = false;
125
127
  arm();
@@ -191,6 +193,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
191
193
  }
192
194
  }
193
195
  pi.on("session_start", (_event, nextCtx) => {
196
+ prewarm.start(nextCtx);
194
197
  stop("session-restart");
195
198
  ctx = nextCtx;
196
199
  attempts = 0;
@@ -199,8 +202,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
199
202
  const usage = lastAssistantUsage(lastMessages);
200
203
  lastUsage = usage;
201
204
  lastCompletedAtMs = lastAssistantTimestamp(lastMessages);
202
- arm();
203
- prewarm.start(nextCtx);
205
+ startupActivity.start(nextCtx, arm);
204
206
  });
205
207
  pi.on("agent_end", (event, nextCtx) => {
206
208
  ctx = nextCtx;
@@ -211,6 +213,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
211
213
  return;
212
214
  }
213
215
  lastMessages = [...event.messages];
216
+ startupActivity.release();
214
217
  lastUsage = usage;
215
218
  lastCompletedAtMs = Date.now();
216
219
  armOnSettle = true;
@@ -241,6 +244,7 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
241
244
  pi.on("input", () => stop("user-input"));
242
245
  pi.on("session_shutdown", () => {
243
246
  stop("session-dispose");
247
+ startupActivity.release();
244
248
  prewarm.cancel();
245
249
  ctx = undefined;
246
250
  });
@@ -11,7 +11,8 @@ export interface SessionPrewarm {
11
11
  cancel(): void;
12
12
  }
13
13
  /**
14
- * One prompt-cache prewarm per session start for OpenAI GPT-5.6+ models (senpi#2096).
14
+ * Arm at session start, then prewarm once on the first draft edit (senpi#3140).
15
+ * A submitted prompt consumes the opportunity: the real request warms its own prefix.
15
16
  * The request is the host's `getPromptCachePrefixRequest()` prefix, the first user turn's
16
17
  * request with an empty conversation, because the platform reuses a prefix only up to a
17
18
  * block boundary: a prewarmed system prompt that stops short of the turn's is never read.
@@ -2,7 +2,8 @@ import { isOpenAIResponsesPromptCacheModel, } from "@earendil-works/pi-ai";
2
2
  import { PROMPT_CACHE_PREWARM_ENTRY_TYPE } from "./prewarm-entry.js";
3
3
  export const PROMPT_CACHE_PREWARM_TIMEOUT_MS = 30_000;
4
4
  /**
5
- * One prompt-cache prewarm per session start for OpenAI GPT-5.6+ models (senpi#2096).
5
+ * Arm at session start, then prewarm once on the first draft edit (senpi#3140).
6
+ * A submitted prompt consumes the opportunity: the real request warms its own prefix.
6
7
  * The request is the host's `getPromptCachePrefixRequest()` prefix, the first user turn's
7
8
  * request with an empty conversation, because the platform reuses a prefix only up to a
8
9
  * block boundary: a prewarmed system prompt that stops short of the turn's is never read.
@@ -15,6 +16,11 @@ export const PROMPT_CACHE_PREWARM_TIMEOUT_MS = 30_000;
15
16
  export function createSessionPrewarm(pi, dependencies) {
16
17
  const isPrewarmModel = dependencies.isPrewarmModel ?? isOpenAIResponsesPromptCacheModel;
17
18
  let current;
19
+ let context;
20
+ let used = false;
21
+ let submitted = false;
22
+ let parked = false;
23
+ let unsubscribeActivity;
18
24
  function cancel() {
19
25
  current?.abort();
20
26
  current = undefined;
@@ -30,6 +36,8 @@ export function createSessionPrewarm(pi, dependencies) {
30
36
  return;
31
37
  }
32
38
  const { request } = prefix;
39
+ if (submitted || ctx.getPromptCacheKeepAliveSettings?.().enabled !== true)
40
+ return;
33
41
  const result = await dependencies.warm(request.model, request.context, {
34
42
  ...request.options,
35
43
  signal,
@@ -53,15 +61,55 @@ export function createSessionPrewarm(pi, dependencies) {
53
61
  function append(data) {
54
62
  pi.appendEntry(PROMPT_CACHE_PREWARM_ENTRY_TYPE, data);
55
63
  }
64
+ function onActivity(kind) {
65
+ if (kind === "prompt") {
66
+ submitted = true;
67
+ used = true;
68
+ return;
69
+ }
70
+ if (context === undefined || parked || used)
71
+ return;
72
+ used = true;
73
+ const ctx = context;
74
+ const model = ctx.model;
75
+ if (ctx.getPromptCacheKeepAliveSettings?.().enabled !== true ||
76
+ model === undefined ||
77
+ model.cacheRetention === "none" ||
78
+ !isPrewarmModel(model) ||
79
+ !ctx.isIdle() ||
80
+ ctx.hasPendingMessages())
81
+ return;
82
+ const controller = new AbortController();
83
+ current = controller;
84
+ void run(ctx, model, controller);
85
+ }
86
+ pi.on("model_select", (_event, ctx) => {
87
+ cancel();
88
+ context = ctx;
89
+ });
90
+ pi.on("session_parked", () => {
91
+ parked = true;
92
+ cancel();
93
+ });
94
+ pi.on("session_resumed", (_event, ctx) => {
95
+ parked = false;
96
+ context = ctx;
97
+ });
98
+ pi.on("session_shutdown", () => {
99
+ unsubscribeActivity?.();
100
+ unsubscribeActivity = undefined;
101
+ context = undefined;
102
+ cancel();
103
+ });
56
104
  return {
57
105
  start(ctx) {
58
106
  cancel();
59
- const model = ctx.model;
60
- if (model === undefined || model.cacheRetention === "none" || !isPrewarmModel(model))
61
- return;
62
- const controller = new AbortController();
63
- current = controller;
64
- void run(ctx, model, controller);
107
+ unsubscribeActivity?.();
108
+ context = ctx;
109
+ used = false;
110
+ submitted = false;
111
+ parked = false;
112
+ unsubscribeActivity = ctx.onUserActivity?.(onActivity);
65
113
  },
66
114
  cancel,
67
115
  };
@@ -0,0 +1,8 @@
1
+ import type { ExtensionContext } from "../../types.ts";
2
+ /** Restored history is not admission; real activity or a completed turn is. */
3
+ export declare function createStartupActivityGate(): {
4
+ readonly waiting: boolean;
5
+ start(ctx: ExtensionContext, onActivity: () => void): void;
6
+ release: () => void;
7
+ };
8
+ //# sourceMappingURL=startup-activity-gate.d.ts.map