openmausbot 0.1.91 → 0.1.93

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 (164) hide show
  1. package/dist/assets/DesktopViewer-CKNR3SEW.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
  3. package/dist/assets/{arc-DsivfZEh.js → arc-BBdBuCgB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
  7. package/dist/assets/channel-BU94VpxE.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-BCAElss-.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
  34. package/dist/assets/index-Biji-bfL.css +1 -0
  35. package/dist/assets/{index-DKNEM8Q6.js → index-BkYwilkG.js} +1 -1
  36. package/dist/assets/index-DZlhoES0.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
  41. package/dist/assets/{linear-yC_29R6e.js → linear-bq1PTDTW.js} +1 -1
  42. package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-s4LTbQle.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +112 -14
  63. package/dist-server/companion/src/routes.js +24 -1
  64. package/dist-server/container-mcp.js +201 -16
  65. package/dist-server/drivers/agents-proxy.js +7 -4
  66. package/dist-server/enterprise/server/index.js +5 -1
  67. package/dist-server/index.js +10698 -4737
  68. package/dist-server/local-computer-proxy.js +133 -8
  69. package/dist-server/mcp-gate.js +16 -6
  70. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  71. package/dist-server/openmausbot.js +4656 -1258
  72. package/dist-server/pair-cli.js +4656 -1258
  73. package/dist-server/server/boat.js +5 -5
  74. package/dist-server/server/browser-engine.js +10 -1
  75. package/dist-server/server/browser-live.js +46 -2
  76. package/dist-server/server/browser-runtime.js +74 -8
  77. package/dist-server/server/browser-tool-shape.js +6 -4
  78. package/dist-server/server/chief-of-staff.js +1 -1
  79. package/dist-server/server/cloud-home-start.js +118 -26
  80. package/dist-server/server/cloud-home.js +39 -3
  81. package/dist-server/server/cloud-lending.js +74 -10
  82. package/dist-server/server/cloud-owner.js +196 -0
  83. package/dist-server/server/cloud-secrets-boot.js +6 -0
  84. package/dist-server/server/cloud-secrets.js +65 -0
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/config.js +187 -14
  87. package/dist-server/server/container-computer.js +46 -34
  88. package/dist-server/server/desktop-viewer-targets.js +26 -0
  89. package/dist-server/server/digest.js +12 -2
  90. package/dist-server/server/drivers/acp/core.js +339 -33
  91. package/dist-server/server/drivers/acp/cursor.js +13 -1
  92. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  93. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  94. package/dist-server/server/drivers/acp/qwen.js +18 -0
  95. package/dist-server/server/drivers/agents-catalog.js +6 -6
  96. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  97. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  98. package/dist-server/server/drivers/antigravity.js +21 -0
  99. package/dist-server/server/drivers/builtIn.js +2 -0
  100. package/dist-server/server/drivers/cerebras.js +108 -0
  101. package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
  102. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  103. package/dist-server/server/drivers/claude.js +329 -37
  104. package/dist-server/server/drivers/codex.js +228 -18
  105. package/dist-server/server/drivers/grok.js +2 -2
  106. package/dist-server/server/drivers/mistral.js +4 -3
  107. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  108. package/dist-server/server/drivers/openai-chat.js +66 -4
  109. package/dist-server/server/drivers/openai-compat.js +73 -23
  110. package/dist-server/server/drivers/prompt-split.js +2 -0
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +21 -5
  113. package/dist-server/server/harness/registry.js +2 -2
  114. package/dist-server/server/index.js +1545 -238
  115. package/dist-server/server/lending-memory.js +479 -0
  116. package/dist-server/server/local-vm-idle.js +24 -6
  117. package/dist-server/server/local-vm-stop-reason.js +26 -0
  118. package/dist-server/server/managed-desktop.js +3 -0
  119. package/dist-server/server/mcp-bridge.js +5 -3
  120. package/dist-server/server/mcp-http.js +6 -3
  121. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  122. package/dist-server/server/mcp-oauth-store.js +70 -0
  123. package/dist-server/server/mcp-oauth.js +550 -0
  124. package/dist-server/server/mcp-probe.js +14 -1
  125. package/dist-server/server/mcp-registry.js +43 -0
  126. package/dist-server/server/memory-upkeep.js +94 -84
  127. package/dist-server/server/model-catalog/catalog.js +374 -0
  128. package/dist-server/server/model-catalog/presets.js +176 -0
  129. package/dist-server/server/model-catalog/trim.js +219 -0
  130. package/dist-server/server/provider-key-check.js +5 -2
  131. package/dist-server/server/recent-work.js +1 -1
  132. package/dist-server/server/request-auth.js +23 -0
  133. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  134. package/dist-server/server/routes/bot-memory.js +31 -6
  135. package/dist-server/server/routes/desktop-viewer.js +238 -0
  136. package/dist-server/server/routine-requests.js +1 -1
  137. package/dist-server/server/routines.js +6 -3
  138. package/dist-server/server/sessions.js +56 -9
  139. package/dist-server/server/skills.js +25 -10
  140. package/dist-server/server/store.js +116 -122
  141. package/dist-server/server/thread-events.js +2 -0
  142. package/dist-server/server/thread-starters.js +33 -0
  143. package/dist-server/server/thread-title.js +46 -0
  144. package/dist-server/server/tts/fish.js +4 -5
  145. package/dist-server/server/tts/index.js +5 -3
  146. package/dist-server/server/vps-computer.js +80 -13
  147. package/dist-server/server/webhooks.js +18 -1
  148. package/dist-server/server/workspace-backup-policy.js +3 -0
  149. package/dist-server/server/workspace-backup.js +25 -0
  150. package/dist-server/server/workspace.js +66 -16
  151. package/dist-server/shared/approval-mode.js +1 -1
  152. package/dist-server/shared/redact.js +1 -0
  153. package/dist-server/shared/tool-name.js +22 -0
  154. package/dist-server/shared/workspace-backup-client.js +1 -0
  155. package/dist-server/src/lib/citations.js +177 -0
  156. package/dist-server/src/lib/composer-attachments.js +7 -1
  157. package/dist-server/vps-container-mcp.js +202 -16
  158. package/dist-server/workspace-backup.worker.js +87 -9
  159. package/enterprise/server/index.js +5 -1
  160. package/package.json +1 -1
  161. package/dist/assets/channel-DrfGMu7X.js +0 -1
  162. package/dist/assets/index-BlnCEcU6.js +0 -588
  163. package/dist/assets/index-DVxjOvID.css +0 -1
  164. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
@@ -31,8 +31,9 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
31
31
  import { createHash } from "node:crypto";
32
32
  import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
33
33
  import { decodeInjectId } from "../local-inject.js";
34
- import { promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt } from "../prompt-split.js";
34
+ import { deletePromptSplitReceipt, promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt } from "../prompt-split.js";
35
35
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
36
+ import { classifyQuiet, describeQuiet, lastSeenPhrase, LogTail, quietKey, sampleProcessTree, } from "./quiet-status.js";
36
37
  /**
37
38
  * A `host::model` pick talks to a loopback server with its own key.
38
39
  * Subscription ACP login (grok.com cached_token) must not fail that turn.
@@ -52,6 +53,10 @@ import { redactSecretsInText } from "../../redact.js";
52
53
  import { recoveryPromptFor } from "../../resume-recovery.js";
53
54
  import { sessionIdlePolicy } from "../session-idle.js";
54
55
  import { classifyError } from "../retry.js";
56
+ const ACCOUNT_ERROR_CODES = new Set([
57
+ "invalid_credentials", "inactive_subscription", "insufficient_funds", "quota_or_region_restriction",
58
+ ]);
59
+ const isAccountError = (code) => code !== undefined && ACCOUNT_ERROR_CODES.has(code);
55
60
  /** ACP vendors put the actionable cause in error.data while keeping the
56
61
  * JSON-RPC message generic. Only surface known text fields, never a response
57
62
  * body/config dump, and redact before bounding the displayed diagnostic. */
@@ -83,8 +88,13 @@ const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 3
83
88
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
84
89
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
85
90
  /** ACP agents may compact their own history without telling the client;
86
- * re-send the full prompt after this many bare turns as a backstop. */
91
+ * re-send the full prompt after this many bare turns as a backstop to
92
+ * compaction detection. */
87
93
  const ACP_PROMPT_RE_ANCHOR_TURNS = 8;
94
+ /** A reported context below this share of the peak marks a compaction.
95
+ * OpenCode compactions measured 30-52% of the peak; ordinary dips stay
96
+ * above 83%. A false alarm costs one extra full prompt. */
97
+ const ACP_COMPACTION_COLLAPSE_RATIO = 0.6;
88
98
  // Read lazily (not at import) so a test can shorten the window. Unlike the
89
99
  // setup calls above, session/prompt legitimately streams for minutes, so a
90
100
  // wall-clock deadline would false-positive: this guard only trips when the
@@ -92,13 +102,23 @@ const ACP_PROMPT_RE_ANCHOR_TURNS = 8;
92
102
  // streams thought chunks, then goes silent forever and never resolves). 0
93
103
  // disables the guard, restoring the pre-fix "hang until the user cancels"
94
104
  // behavior.
105
+ //
106
+ // Healthy agents go silent for minutes too. Qwen Code 0.24 sends nothing
107
+ // over ACP while it compresses history, while it backs off a rate limit
108
+ // (60 s up to 5 min per wait), or while one model request runs (its SDK
109
+ // waits up to 600 s). The old 180 s default killed those turns mid-work,
110
+ // and the retry redid it all. A false trip costs the whole turn; a real
111
+ // wedge only costs waiting, and the user can press Stop - so the default
112
+ // sits above the longest normal silence.
113
+ export const DEFAULT_ACP_PROMPT_IDLE_MS = 15 * 60_000;
95
114
  const promptIdleTimeoutMs = () => {
96
115
  const raw = process.env.OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS;
97
116
  if (raw === undefined)
98
- return 180_000;
117
+ return DEFAULT_ACP_PROMPT_IDLE_MS;
99
118
  const ms = Number(raw);
100
119
  return Number.isFinite(ms) && ms > 0 ? ms : 0;
101
120
  };
121
+ const formatQuietLimit = (ms) => ms >= 120_000 && ms % 60_000 === 0 ? `${ms / 60_000} min` : `${Math.round(ms / 1000)} s`;
102
122
  /** Keep a turn's set of running tool calls in step with the agent's
103
123
  * `tool_call` / `tool_call_update` notifications: a call is running from
104
124
  * the first update that is not terminal until one that is. A `tool_call`
@@ -141,6 +161,24 @@ function acpVariantOption(result) {
141
161
  };
142
162
  }
143
163
  const TOOL_LOG_TEXT_LIMIT = 64_000;
164
+ /** Every model id a session config snapshot offers under `configId`,
165
+ * flattening grouped options. Empty when the agent listed none. */
166
+ export function offeredModels(result, configId) {
167
+ const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === configId);
168
+ const ids = [];
169
+ const visit = (entries) => {
170
+ if (!Array.isArray(entries))
171
+ return;
172
+ for (const entry of entries) {
173
+ if (typeof entry?.value === "string")
174
+ ids.push(entry.value);
175
+ else if (Array.isArray(entry?.options))
176
+ visit(entry.options);
177
+ }
178
+ };
179
+ visit(option?.options);
180
+ return ids;
181
+ }
144
182
  async function readAcpImageBlocks(images) {
145
183
  return Promise.all(images.map(async (image) => ({
146
184
  type: "image",
@@ -250,6 +288,11 @@ export function versionAtLeast(installed, floor) {
250
288
  return true;
251
289
  }
252
290
  export function createAcpDriver(support) {
291
+ // Without a per-turn configureSession nothing would ever apply the mode
292
+ // that sessionScopedApproval keeps out of the spawn contract.
293
+ if (support.sessionScopedApproval && !support.configureSession) {
294
+ throw new Error(`${support.displayName}: sessionScopedApproval needs configureSession to apply each turn's mode`);
295
+ }
253
296
  const DRIVER_KIND = support.driverKind;
254
297
  const SOURCE = support.nativeSource;
255
298
  const decodeConfig = decodeAcpConfig(support.defaultCli);
@@ -289,13 +332,18 @@ export function createAcpDriver(support) {
289
332
  return env;
290
333
  };
291
334
  let models = support.models;
335
+ // Models this account itself offered (see sessionModelEnv): the
336
+ // discovered catalog, never the static one, or a live session's list.
337
+ let accountModels = null;
292
338
  const refreshModels = async () => {
293
339
  if (!support.resolveModels)
294
340
  return;
295
341
  try {
296
342
  const resolved = await support.resolveModels(childEnv(), config, instanceId);
297
- if (resolved.options.length)
343
+ if (resolved.options.length) {
298
344
  models = resolved;
345
+ accountModels = new Set(resolved.options.map((option) => option.id));
346
+ }
299
347
  }
300
348
  catch {
301
349
  // Keep the last usable catalog when an optional discovery source is down.
@@ -463,6 +511,101 @@ export function createAcpDriver(support) {
463
511
  }
464
512
  return servers;
465
513
  };
514
+ /** While a prompt runs, notice when the agent goes quiet and tell the
515
+ * person what it is doing: retrying, compressing, waiting on its
516
+ * model, busy, or showing no sign of life (see quiet-status.ts). A
517
+ * notice is sent only when that answer changes. New lines in the
518
+ * agent's own log also restart the prompt's idle deadline, so a
519
+ * turn that is visibly retrying is never stopped as stuck. */
520
+ const startQuietWatch = (threadId, session, current) => {
521
+ const noticeAfterMs = envOr("OMB_ACP_QUIET_NOTICE_MS", 60_000);
522
+ const tickMs = envOr("OMB_ACP_QUIET_TICK_MS", 15_000);
523
+ const logPath = support.statusLog && session.sessionId
524
+ ? support.statusLog.path(session.env, session.sessionId)
525
+ : null;
526
+ const tail = logPath ? new LogTail(logPath) : null;
527
+ tail?.read(); // skip what this log held before the prompt
528
+ let logged = null;
529
+ let context = null;
530
+ let previous = null;
531
+ let previousAt = 0;
532
+ let shownKey = null;
533
+ let noSignsSince = null;
534
+ let notices = 0;
535
+ let probing = false;
536
+ let state = null;
537
+ const tick = async () => {
538
+ if (probing || current.state.settled || session.current !== current)
539
+ return;
540
+ const now = Date.now();
541
+ for (const line of tail?.read() ?? []) {
542
+ session.acp.touch();
543
+ const signal = support.statusLog.parse(line);
544
+ if (signal?.kind === "context")
545
+ context = { tokens: signal.tokens, threshold: signal.threshold };
546
+ else if (signal)
547
+ logged = { signal, at: now };
548
+ }
549
+ const quietMs = now - session.acp.lastInboundAt;
550
+ // A running tool or an open question is already on screen.
551
+ if (quietMs < noticeAfterMs || current.asks.size || current.runningTools.size) {
552
+ if (quietMs < noticeAfterMs)
553
+ logged = null;
554
+ previous = null;
555
+ shownKey = null;
556
+ noSignsSince = null;
557
+ state = null;
558
+ return;
559
+ }
560
+ // A retry stays the answer until well past its wait (Qwen's SDK
561
+ // retries on its own for about a minute before the next logged
562
+ // attempt); after that the probe takes over.
563
+ if (logged?.signal.kind === "retry"
564
+ && now - logged.at > (logged.signal.delayMs ?? 60_000) + 120_000)
565
+ logged = null;
566
+ probing = true;
567
+ const sample = session.child.pid ? await sampleProcessTree(session.child.pid) : { cpuMs: null, connections: null };
568
+ probing = false;
569
+ if (current.state.settled || session.current !== current)
570
+ return;
571
+ let next = classifyQuiet({
572
+ logged: logged?.signal ?? null, context, sample, previous, sinceMs: previous ? now - previousAt : 0,
573
+ });
574
+ const first = previous === null;
575
+ previous = sample;
576
+ previousAt = now;
577
+ // CPU use needs two samples; the first only sets the baseline
578
+ // (unless the log already said what is going on).
579
+ if (first && next.kind === "unknown")
580
+ return;
581
+ // An SDK sleeping between its own retries holds no socket and
582
+ // burns no CPU (seen with Qwen against a 429ing endpoint), so
583
+ // "no sign of life" is called only once that has lasted minutes.
584
+ if (next.kind === "no-signs") {
585
+ noSignsSince ??= now;
586
+ if (now - noSignsSince < envOr("OMB_ACP_STUCK_AFTER_MS", 180_000))
587
+ next = { kind: "between-requests" };
588
+ }
589
+ else {
590
+ noSignsSince = null;
591
+ }
592
+ state = next;
593
+ const key = quietKey(next);
594
+ if (key === shownKey || notices >= 12)
595
+ return;
596
+ shownKey = key;
597
+ notices += 1;
598
+ emit({
599
+ ...base(threadId, current.turnId),
600
+ type: "runtime.notice",
601
+ message: describeQuiet(support.displayName, next, quietMs, promptIdleTimeoutMs()),
602
+ });
603
+ };
604
+ const timer = setInterval(() => { void tick(); }, tickMs);
605
+ timer.unref?.();
606
+ current.stopQuietWatch = () => clearInterval(timer);
607
+ return { lastState: () => state };
608
+ };
466
609
  /** The one completion path for a turn: the prompt result, a crashed
467
610
  * child, an unanswered cancel, or an rpc error the turn body throws.
468
611
  * The child is NOT killed here — a clean settle leaves it pooled for
@@ -473,6 +616,7 @@ export function createAcpDriver(support) {
473
616
  return;
474
617
  current.state.settled = true;
475
618
  current.acknowledge();
619
+ current.stopQuietWatch?.();
476
620
  if (current.interruptTimer)
477
621
  clearTimeout(current.interruptTimer);
478
622
  for (const finish of current.asks.values())
@@ -565,7 +709,7 @@ export function createAcpDriver(support) {
565
709
  return;
566
710
  }
567
711
  rpcPending.delete(id);
568
- const error = new Error(idleMessage ?? `${method} stopped responding`);
712
+ const error = new Error((typeof idleMessage === "function" ? idleMessage() : idleMessage) ?? `${method} stopped responding`);
569
713
  Object.assign(error, { acpPromptStall: true });
570
714
  reject(error);
571
715
  }, idleMs);
@@ -584,9 +728,13 @@ export function createAcpDriver(support) {
584
728
  });
585
729
  send({ jsonrpc: "2.0", id, method, params });
586
730
  });
731
+ let lastInboundAt = Date.now();
587
732
  const acp = {
588
733
  send,
589
734
  request,
735
+ get lastInboundAt() { return lastInboundAt; },
736
+ touch: () => { for (const p of rpcPending.values())
737
+ p.armIdle(); },
590
738
  failAll: (error) => {
591
739
  for (const p of rpcPending.values()) {
592
740
  if (p.timer)
@@ -884,12 +1032,26 @@ export function createAcpDriver(support) {
884
1032
  }
885
1033
  break;
886
1034
  }
1035
+ case "usage_update": {
1036
+ // opencode 1.18 sends `used` flat; ignore non-positive reports
1037
+ const used = u.used ?? u.usage?.used;
1038
+ if (typeof used !== "number" || !(used > 0))
1039
+ break;
1040
+ if (current.state.usagePeak !== null && used < current.state.usagePeak * ACP_COMPACTION_COLLAPSE_RATIO) {
1041
+ current.state.usageCompacted = true;
1042
+ }
1043
+ if (current.state.usagePeak === null || used > current.state.usagePeak)
1044
+ current.state.usagePeak = used;
1045
+ current.state.usageLast = used;
1046
+ break;
1047
+ }
887
1048
  }
888
1049
  };
889
1050
  const session = {
890
1051
  child,
891
1052
  acp,
892
1053
  launch,
1054
+ env,
893
1055
  cwd,
894
1056
  contractKey,
895
1057
  sessionKey: null,
@@ -925,6 +1087,7 @@ export function createAcpDriver(support) {
925
1087
  appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
926
1088
  // Inbound traffic proves the child is alive and making progress,
927
1089
  // so every idle deadline restarts; only total silence trips it.
1090
+ lastInboundAt = Date.now();
928
1091
  for (const p of rpcPending.values())
929
1092
  p.armIdle();
930
1093
  if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
@@ -1026,8 +1189,12 @@ export function createAcpDriver(support) {
1026
1189
  return { turnId };
1027
1190
  }
1028
1191
  const resolvedModel = support.resolveTurnModel?.(turn.model, env);
1029
- support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
1030
- const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
1192
+ support.applyTurnEnv?.(env, {
1193
+ model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true, botId: turn.botId, cwd,
1194
+ });
1195
+ // Rebound once, before the prompt, when a fallbackModel support swaps
1196
+ // a model this session does not offer for one it does.
1197
+ let cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
1031
1198
  ? { ...turn, model: resolvedModel }
1032
1199
  : turn;
1033
1200
  const mcpServers = acpMcpServers(turn);
@@ -1067,7 +1234,31 @@ export function createAcpDriver(support) {
1067
1234
  // riding a child spawned under the old env. Hash it so secrets
1068
1235
  // never sit in the key itself.
1069
1236
  const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
1070
- const contractKey = JSON.stringify([launch.command, launch.args ?? [], spawnArgs, cwd, turnConfig.fullAuto === true, envFingerprint]);
1237
+ // A support that applies the approval mode to the session on every
1238
+ // turn gets the same process whatever the mode: an approval change
1239
+ // is one RPC, not a cold start.
1240
+ const contractKey = JSON.stringify([
1241
+ launch.command, launch.args ?? [], spawnArgs, cwd,
1242
+ support.sessionScopedApproval ? null : turnConfig.fullAuto === true, envFingerprint,
1243
+ support.spawnFingerprint?.(spawnEnv) ?? null,
1244
+ ]);
1245
+ // The new-session default model rides the spawn env but stays out of
1246
+ // the fingerprint above: only session/new reads it, and a pooled
1247
+ // process switches later models over the wire as before.
1248
+ const launchModel = support.sessionModelEnv && cliTurn.model && /^[\w.:/-]{1,200}$/u.test(cliTurn.model)
1249
+ && accountModels?.has(cliTurn.model) ? cliTurn.model : undefined;
1250
+ const launchEnv = support.sessionModelEnv && launchModel
1251
+ ? { ...spawnEnv, [support.sessionModelEnv]: launchModel }
1252
+ : spawnEnv;
1253
+ // whether this turn's process was started by this turn: its model
1254
+ // list is then current, not a pooled process's older one
1255
+ let launchedThisTurn = false;
1256
+ const launchSession = () => {
1257
+ const opened = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], launchEnv, cwd, contractKey);
1258
+ opened.launchModel = launchModel;
1259
+ launchedThisTurn = true;
1260
+ return opened;
1261
+ };
1071
1262
  const sessionKey = JSON.stringify(mcpServers);
1072
1263
  if (turn.sessionReset) {
1073
1264
  closeSession(threadId, "reset");
@@ -1092,14 +1283,14 @@ export function createAcpDriver(support) {
1092
1283
  else
1093
1284
  closeSession(threadId, "contract");
1094
1285
  }
1095
- session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
1286
+ session = launchSession();
1096
1287
  sessions.set(threadId, session);
1097
1288
  }
1098
1289
  // `session` rebinds mid-turn: when the establishment retry below
1099
1290
  // respawns the child, every wire call must reach the live record, so
1100
1291
  // nothing captures the connection off it.
1101
1292
  const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
1102
- const state = { settled: false, promptSent: false, text: "", producedItem: false, startupActivity: false, stopped: false };
1293
+ const state = { settled: false, promptSent: false, text: "", producedItem: false, startupActivity: false, stopped: false, usagePeak: null, usageLast: null, usageCompacted: false };
1103
1294
  let acknowledge = () => { };
1104
1295
  let rejectStartup = (_error) => { };
1105
1296
  const startupAck = turn.startupRecovery ? new Promise((resolve, reject) => {
@@ -1107,6 +1298,16 @@ export function createAcpDriver(support) {
1107
1298
  rejectStartup = reject;
1108
1299
  }) : null;
1109
1300
  const asks = new Map();
1301
+ // What this turn reports and applies. A model fallback (below)
1302
+ // replaces both: a saved variant belongs to the model it replaced.
1303
+ let reportedModel = turn.model;
1304
+ let variant = turn.variant;
1305
+ // set below when this process lists fewer models than the catalog
1306
+ let staleProcess = false;
1307
+ // Set while a sessionScopedApproval support applies this turn's mode.
1308
+ // Still set if that throws: the pooled session may be left on an
1309
+ // earlier turn's looser mode, so its process must not be reused.
1310
+ let approvalUnconfirmed = false;
1110
1311
  const modelOf = (result) => {
1111
1312
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
1112
1313
  return typeof option?.currentValue === "string" ? option.currentValue : null;
@@ -1122,7 +1323,7 @@ export function createAcpDriver(support) {
1122
1323
  emit({
1123
1324
  ...base(threadId, turnId),
1124
1325
  type: "session.model-variants",
1125
- model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
1326
+ model: nativeModel === cliTurn.model ? (reportedModel ?? nativeModel) : nativeModel,
1126
1327
  variants: {
1127
1328
  options: option?.options ?? [],
1128
1329
  ...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
@@ -1133,8 +1334,8 @@ export function createAcpDriver(support) {
1133
1334
  if (!support.modelVariants)
1134
1335
  throw new Error(`${support.displayName} does not support model variants`);
1135
1336
  const option = acpVariantOption(session.sessionConfigResult);
1136
- if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
1137
- throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
1337
+ if (!option || !option.options.some((entry) => entry.id === variant)) {
1338
+ throw new Error(`${support.displayName} does not advertise variant ${variant} for this model`);
1138
1339
  }
1139
1340
  return option;
1140
1341
  };
@@ -1157,6 +1358,7 @@ export function createAcpDriver(support) {
1157
1358
  asks,
1158
1359
  runningTools: new Set(),
1159
1360
  interruptTimer: null,
1361
+ stopQuietWatch: null,
1160
1362
  flushAssistantText,
1161
1363
  receiveModelVariants,
1162
1364
  };
@@ -1182,6 +1384,7 @@ export function createAcpDriver(support) {
1182
1384
  emit({ ...base(threadId, turnId), type: "turn.started" });
1183
1385
  session.current = current;
1184
1386
  (async () => {
1387
+ let pendingSplitReceipt = null;
1185
1388
  try {
1186
1389
  // The handshake is paid once per process, not once per turn. It
1187
1390
  // is a function so the establishment retry below can pay it
@@ -1304,7 +1507,7 @@ export function createAcpDriver(support) {
1304
1507
  // session/new on the replacement child.
1305
1508
  session.current = null;
1306
1509
  closeSession(threadId, "reestablish");
1307
- session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
1510
+ session = launchSession();
1308
1511
  sessions.set(threadId, session);
1309
1512
  session.current = current;
1310
1513
  runtimeAcceptsImages = await handshake();
@@ -1352,6 +1555,43 @@ export function createAcpDriver(support) {
1352
1555
  if (support.selectModel) {
1353
1556
  const { configId } = support.selectModel;
1354
1557
  selectedModel = modelOf(session.sessionConfigResult);
1558
+ const wanted = cliTurn.model;
1559
+ const offered = offeredModels(session.sessionConfigResult, configId);
1560
+ // A process started without a default model lists exactly
1561
+ // what this account offers.
1562
+ if (support.sessionModelEnv && !session.launchModel && offered.length)
1563
+ accountModels = new Set(offered);
1564
+ // The instance catalog lists the model but a pooled process
1565
+ // does not: the process is older than the catalog (a provider
1566
+ // added since it started). That is no reason to run another
1567
+ // model. The switch below fails with the runtime's own words,
1568
+ // and the process is closed so the retry starts on a fresh
1569
+ // one. A process this turn started is current, so there the
1570
+ // catalog is the stale one and the fallback applies.
1571
+ staleProcess = Boolean(support.fallbackModel && wanted && !launchedThisTurn && offered.length
1572
+ && !offered.includes(wanted) && models.options.some((option) => option.id === wanted));
1573
+ // A local inject writes its provider for this turn, so a
1574
+ // process started earlier may not list it yet: never replace it.
1575
+ if (support.fallbackModel && offered.length && !skipSubscriptionAuthForLocalInject(turn.model)
1576
+ && !staleProcess && (!wanted || !offered.includes(wanted))) {
1577
+ const fallback = support.fallbackModel(session.sessionConfigResult, models.default);
1578
+ if (fallback && fallback !== wanted && offered.includes(fallback)) {
1579
+ const notice = wanted ? `${wanted}\n${fallback}` : null;
1580
+ if (notice && session.fallbackNotice !== notice) {
1581
+ session.fallbackNotice = notice;
1582
+ emit({
1583
+ ...base(threadId, turnId),
1584
+ type: "runtime.notice",
1585
+ message: `${support.displayName} no longer offers ${wanted}, so this conversation uses ${fallback}. ` +
1586
+ "Choose another model for this bot to stop seeing this.",
1587
+ });
1588
+ }
1589
+ cliTurn = { ...cliTurn, model: fallback };
1590
+ current.turn = cliTurn;
1591
+ reportedModel = fallback;
1592
+ variant = undefined;
1593
+ }
1594
+ }
1355
1595
  if (cliTurn.model && cliTurn.model !== selectedModel) {
1356
1596
  sessionResult = await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT, receiveModelVariants);
1357
1597
  selectedModel = modelOf(session.sessionConfigResult);
@@ -1363,6 +1603,7 @@ export function createAcpDriver(support) {
1363
1603
  }
1364
1604
  }
1365
1605
  if (support.configureSession) {
1606
+ approvalUnconfirmed = support.sessionScopedApproval === true;
1366
1607
  await support.configureSession({
1367
1608
  request: (method, params, timeoutMs) => request(method, params, timeoutMs ?? SESSION_CONFIG_TIMEOUT),
1368
1609
  sessionId,
@@ -1372,17 +1613,18 @@ export function createAcpDriver(support) {
1372
1613
  ? sessionResult.models.availableModels
1373
1614
  : [],
1374
1615
  });
1616
+ approvalUnconfirmed = false;
1375
1617
  // initialize's currentModelId is the CLI default,
1376
1618
  // not the model this turn asked for. After a successful pin,
1377
1619
  // report the slug we set so the UI does not claim otherwise.
1378
1620
  if (!selectedModel && cliTurn.model)
1379
1621
  selectedModel = cliTurn.model;
1380
1622
  }
1381
- if (turn.variant !== undefined) {
1623
+ if (variant !== undefined) {
1382
1624
  const option = requestedVariantOption();
1383
- await request("session/set_config_option", { sessionId, configId: option.configId, value: turn.variant }, SESSION_CONFIG_TIMEOUT, receiveModelVariants);
1384
- if (requestedVariantOption().currentValue !== turn.variant) {
1385
- throw new Error(`${support.displayName} did not apply variant ${turn.variant}`);
1625
+ await request("session/set_config_option", { sessionId, configId: option.configId, value: variant }, SESSION_CONFIG_TIMEOUT, receiveModelVariants);
1626
+ if (requestedVariantOption().currentValue !== variant) {
1627
+ throw new Error(`${support.displayName} did not apply variant ${variant}`);
1386
1628
  }
1387
1629
  }
1388
1630
  }
@@ -1403,12 +1645,15 @@ export function createAcpDriver(support) {
1403
1645
  // adapter call) keeps the legacy full-prompt shape.
1404
1646
  const halves = promptHalves(turn);
1405
1647
  let promptInput = promptTurn;
1406
- let pendingSplitReceipt = null;
1407
1648
  if (halves.stable !== null) {
1408
1649
  const receiptKey = JSON.stringify([threadId, sessionId]);
1409
- const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt(DRIVER_KIND, receiptKey), promptTurn.system, promptTurn.text, Boolean(turn.mentionTurn), ACP_PROMPT_RE_ANCHOR_TURNS);
1650
+ const previousReceipt = readPromptSplitReceipt(DRIVER_KIND, receiptKey);
1651
+ // Carry the high-water mark, not just the final report: several
1652
+ // ordinary dips across turns can add up to a compaction.
1653
+ state.usagePeak = previousReceipt?.peakUsed ?? previousReceipt?.lastUsed ?? null;
1654
+ const composed = splitSessionPrompt(halves.stable, halves.volatile, previousReceipt, promptTurn.system, promptTurn.text, Boolean(turn.mentionTurn), ACP_PROMPT_RE_ANCHOR_TURNS);
1410
1655
  promptInput = { ...promptTurn, system: "", text: composed.text };
1411
- pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt };
1656
+ pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt, previous: previousReceipt };
1412
1657
  }
1413
1658
  const text = support.buildPromptText
1414
1659
  ? support.buildPromptText(promptInput)
@@ -1421,7 +1666,7 @@ export function createAcpDriver(support) {
1421
1666
  if (support.modelVariants && cliTurn.model && modelOf(session.sessionConfigResult) !== cliTurn.model) {
1422
1667
  throw new Error(`${support.displayName} changed model before the prompt`);
1423
1668
  }
1424
- if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
1669
+ if (variant !== undefined && requestedVariantOption().currentValue !== variant) {
1425
1670
  throw new Error(`${support.displayName} changed variant before the prompt`);
1426
1671
  }
1427
1672
  if (state.settled || state.stopped || session.closing)
@@ -1429,23 +1674,62 @@ export function createAcpDriver(support) {
1429
1674
  state.promptSent = true;
1430
1675
  acknowledge();
1431
1676
  const promptIdleMs = promptIdleTimeoutMs();
1432
- const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, `${DRIVER_KIND} sent nothing for ${Math.round(promptIdleMs / 1000)} s with no tool running, so the turn was stopped as stuck. ` +
1433
- "Send the message again to retry. On a self-hosted server, OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS sets this limit (0 turns it off).");
1677
+ const quiet = startQuietWatch(threadId, session, current);
1678
+ const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, () => {
1679
+ const last = quiet.lastState();
1680
+ return `${DRIVER_KIND} sent nothing for ${formatQuietLimit(promptIdleMs)} with no tool running, so the turn was stopped as stuck` +
1681
+ `${last ? ` (last seen: ${lastSeenPhrase(last)})` : ""}. ` +
1682
+ "Send the message again to retry. On a self-hosted server, OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS sets this limit (0 turns it off).";
1683
+ });
1434
1684
  if (pendingSplitReceipt) {
1435
1685
  // session/prompt resolving is the acceptance boundary: a
1436
1686
  // rejected prompt leaves the receipt unwritten, so the next
1437
- // turn redelivers what this one never received.
1438
- writePromptSplitReceipt(DRIVER_KIND, pendingSplitReceipt.key, pendingSplitReceipt.receipt);
1687
+ // turn redelivers what this one never received. After a
1688
+ // compaction, drop it so the next turn re-sends the full prompt.
1689
+ if (state.usageCompacted) {
1690
+ deletePromptSplitReceipt(DRIVER_KIND, pendingSplitReceipt.key);
1691
+ }
1692
+ else {
1693
+ const previousLastUsed = typeof pendingSplitReceipt.previous?.lastUsed === "number"
1694
+ ? pendingSplitReceipt.previous.lastUsed
1695
+ : undefined;
1696
+ const lastUsed = state.usageLast ?? previousLastUsed;
1697
+ writePromptSplitReceipt(DRIVER_KIND, pendingSplitReceipt.key, {
1698
+ ...pendingSplitReceipt.receipt,
1699
+ ...(lastUsed === undefined ? {} : { lastUsed }),
1700
+ ...(state.usagePeak === null ? {} : { peakUsed: state.usagePeak }),
1701
+ });
1702
+ }
1439
1703
  }
1440
1704
  // opencode 1.18.18 reports usage at the result root; grok and
1441
1705
  // gemini put it under _meta. Read both rather than lose the count.
1442
1706
  const usage = result?.usage ?? result?._meta ?? {};
1443
1707
  if (typeof usage.inputTokens === "number" || typeof usage.outputTokens === "number") {
1708
+ // opencode 1.18.25 keeps only the uncached share in inputTokens
1709
+ // and reports cache reads beside it (omitted when 0); some
1710
+ // agents count them inside inputTokens. `input` is the whole
1711
+ // prompt with `cachedInput` naming its cached part — the same
1712
+ // convention as the Claude driver and the store — and
1713
+ // totalTokens tells the two wire shapes apart. Cache writes
1714
+ // stay out of `input`, exactly as in the Claude driver, and an
1715
+ // absent cache count emits the bare shape with no cachedInput
1716
+ // key: absent is not zero.
1717
+ const input = usage.inputTokens ?? 0;
1718
+ const output = usage.outputTokens ?? 0;
1719
+ const cachedRead = typeof usage.cachedReadTokens === "number" && usage.cachedReadTokens > 0
1720
+ ? usage.cachedReadTokens
1721
+ : null;
1722
+ const exclusive = cachedRead !== null
1723
+ && (typeof usage.totalTokens !== "number"
1724
+ || usage.totalTokens >= input + output + cachedRead);
1444
1725
  emit({
1445
1726
  ...base(threadId, turnId),
1446
1727
  type: "thread.token-usage.updated",
1447
- input: usage.inputTokens ?? 0,
1448
- output: usage.outputTokens ?? 0,
1728
+ input: cachedRead !== null && exclusive ? input + cachedRead : input,
1729
+ output,
1730
+ ...(cachedRead !== null
1731
+ ? { cachedInput: exclusive ? cachedRead : Math.min(cachedRead, input) }
1732
+ : {}),
1449
1733
  });
1450
1734
  }
1451
1735
  const reason = result?.stopReason;
@@ -1468,6 +1752,11 @@ export function createAcpDriver(support) {
1468
1752
  }
1469
1753
  }
1470
1754
  catch (e) {
1755
+ // A rejected prompt can still have compacted native history. Its
1756
+ // old receipt must not suppress the next turn's standing rules.
1757
+ if (pendingSplitReceipt && state.promptSent && state.usageCompacted) {
1758
+ deletePromptSplitReceipt(DRIVER_KIND, pendingSplitReceipt.key);
1759
+ }
1471
1760
  if (!state.settled) {
1472
1761
  const message = e instanceof Error ? e.message : String(e);
1473
1762
  const code = support.classifyError?.(e);
@@ -1475,12 +1764,19 @@ export function createAcpDriver(support) {
1475
1764
  // classifier is preferred; loginNote remains a compatibility
1476
1765
  // fallback for existing ACP supports.
1477
1766
  const needsAuth = code === "invalid_credentials" || code === "inactive_subscription"
1478
- || message === support.loginNote;
1767
+ || code === "insufficient_funds" || message === support.loginNote;
1768
+ // A key, credit, quota or subscription problem is the account's,
1769
+ // not the process's: the child that reported it is healthy, so it
1770
+ // stays pooled and the retry after a fix starts warm.
1771
+ const accountError = isAccountError(code);
1479
1772
  const failure = classifyError({ text: message });
1480
1773
  const transientStartup = failure.transient || (failure.reason === "unknown" && code === "upstream_outage");
1481
1774
  const denied = /\b(?:(?:permission|access) denied|(?:approval|permission) (?:required|denied|rejected)|requires? (?:approval|permission)|policy (?:restriction|violation)|(?:blocked|denied|restricted) by (?:the )?policy)\b/i.test(message);
1482
- if (turn.startupRecovery && transientStartup && !needsAuth && !denied && (!code || code === "upstream_outage")
1483
- && ![-32700, -32600, -32601, -32602].includes(e?.code)
1775
+ // An unconfirmed approval switch sent no prompt and its process
1776
+ // is discarded either way, so a fresh one can take the turn.
1777
+ const retryableStartup = approvalUnconfirmed || (transientStartup && (!code || code === "upstream_outage")
1778
+ && ![-32700, -32600, -32601, -32602].includes(e?.code));
1779
+ if (turn.startupRecovery && retryableStartup && !needsAuth && !accountError && !denied
1484
1780
  && !state.promptSent && !state.startupActivity && !state.producedItem && !state.text
1485
1781
  && !state.stopped && !session.closing && !asks.size && !current.runningTools.size) {
1486
1782
  // Quiesce before killing: the child's close event must not
@@ -1508,7 +1804,7 @@ export function createAcpDriver(support) {
1508
1804
  emit({
1509
1805
  ...base(threadId, turnId),
1510
1806
  type: "runtime.error",
1511
- message,
1807
+ message: accountError ? support.describeAccountError?.(code, cliTurn.model) ?? message : message,
1512
1808
  ...(needsAuth ? { setup: true } : {}),
1513
1809
  });
1514
1810
  // Internal RPC failures can leave a live child poisoned just
@@ -1516,10 +1812,20 @@ export function createAcpDriver(support) {
1516
1812
  // start the next turn. Never replay this accepted prompt: it
1517
1813
  // may already have executed tools. The next explicit turn can
1518
1814
  // resume the recorded session on a fresh process.
1519
- if (!needsAuth && (e?.acpPromptStall === true || e?.acpSessionFailure === true
1815
+ if (!needsAuth && !accountError && (e?.acpPromptStall === true || e?.acpSessionFailure === true
1520
1816
  || code === "upstream_outage") && session.child.exitCode === null && !session.closing) {
1521
1817
  closeSession(threadId, e?.acpPromptStall === true ? "prompt-stall" : "rpc-failure");
1522
1818
  }
1819
+ else if (approvalUnconfirmed && session.child.exitCode === null && !session.closing) {
1820
+ // Fail closed: the session may still hold a looser mode from
1821
+ // an earlier turn. Nothing was prompted; the next turn starts
1822
+ // a fresh process, whose session begins in the runtime's own
1823
+ // default mode, and applies its mode there.
1824
+ closeSession(threadId, "approval-unconfirmed");
1825
+ }
1826
+ else if (staleProcess && !state.promptSent && session.child.exitCode === null && !session.closing) {
1827
+ closeSession(threadId, "stale-models");
1828
+ }
1523
1829
  settle(threadId, session, false, needsAuth ? "auth_required" : "rpc_error");
1524
1830
  }
1525
1831
  }