openmausbot 0.1.92 → 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 (104) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-CKNR3SEW.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-BBdBuCgB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
  7. package/dist/assets/channel-BU94VpxE.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-BCAElss-.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
  34. package/dist/assets/index-Biji-bfL.css +1 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-BkYwilkG.js} +1 -1
  36. package/dist/assets/index-DZlhoES0.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-bq1PTDTW.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-s4LTbQle.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +24 -1
  62. package/dist-server/container-mcp.js +6 -1
  63. package/dist-server/drivers/agents-proxy.js +2 -0
  64. package/dist-server/index.js +1722 -906
  65. package/dist-server/local-computer-proxy.js +5 -1
  66. package/dist-server/mcp-gate.js +5 -1
  67. package/dist-server/openmausbot.js +763 -93
  68. package/dist-server/pair-cli.js +763 -93
  69. package/dist-server/server/boat.js +3 -3
  70. package/dist-server/server/cloud-owner.js +1 -1
  71. package/dist-server/server/computer-control.js +6 -0
  72. package/dist-server/server/config.js +14 -1
  73. package/dist-server/server/container-computer.js +43 -32
  74. package/dist-server/server/digest.js +12 -2
  75. package/dist-server/server/drivers/acp/core.js +172 -12
  76. package/dist-server/server/drivers/acp/cursor.js +13 -1
  77. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  78. package/dist-server/server/drivers/acp/qwen.js +18 -0
  79. package/dist-server/server/drivers/builtIn.js +2 -0
  80. package/dist-server/server/drivers/cerebras.js +108 -0
  81. package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
  82. package/dist-server/server/drivers/claude.js +17 -3
  83. package/dist-server/server/drivers/openai-chat.js +26 -2
  84. package/dist-server/server/drivers/prompt-split.js +2 -0
  85. package/dist-server/server/env-path.js +6 -2
  86. package/dist-server/server/index.js +210 -57
  87. package/dist-server/server/local-vm-stop-reason.js +26 -0
  88. package/dist-server/server/provider-key-check.js +2 -1
  89. package/dist-server/server/routes/desktop-viewer.js +50 -6
  90. package/dist-server/server/routines.js +6 -3
  91. package/dist-server/server/store.js +54 -0
  92. package/dist-server/server/vps-computer.js +1 -1
  93. package/dist-server/server/workspace-backup.js +22 -0
  94. package/dist-server/server/workspace.js +1 -1
  95. package/dist-server/shared/approval-mode.js +1 -1
  96. package/dist-server/shared/redact.js +1 -0
  97. package/dist-server/shared/tool-name.js +22 -0
  98. package/dist-server/vps-container-mcp.js +6 -1
  99. package/dist-server/workspace-backup.worker.js +14 -0
  100. package/package.json +1 -1
  101. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  102. package/dist/assets/index-Cdmjrl28.css +0 -1
  103. package/dist/assets/index-DR7YPMkW.js +0 -597
  104. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -923,9 +923,9 @@ export async function verifyToken(token) {
923
923
  return { ok: false, message: "Couldn't reach boat.dev to check that token — check your connection and retry." };
924
924
  }
925
925
  }
926
- /** A rejected Cloud Pro relay token: nothing the person pasted, so nothing
927
- * for them to fix in Settings. */
928
- const INCLUDED_BOAT_UNAVAILABLE = "Cloud Pro's included cloud computers aren't available right now. Try again later.";
926
+ /** A rejected Cloud relay token: nothing the person pasted, so nothing
927
+ * for them to fix in Settings. Plan-neutral: every Cloud plan includes them. */
928
+ const INCLUDED_BOAT_UNAVAILABLE = "The cloud computers included with your Cloud plan aren't available right now. Try again later.";
929
929
  /** Turn a provider refusal into something a person can act on. The
930
930
  * provider's own message is better than anything we can invent — it knows
931
931
  * the plan, the limit and the link — so prefer it and only fall back to
@@ -29,7 +29,7 @@
29
29
  import { lstatSync, readFileSync } from "node:fs";
30
30
  import { z } from "zod";
31
31
  import { writeFileAtomic } from "./atomic.js";
32
- export const CLOUD_PERSONAL_REFUSAL = "Cloud Pro is personal: only your own devices can connect.";
32
+ export const CLOUD_PERSONAL_REFUSAL = "OMB Cloud is personal: only your own devices can connect.";
33
33
  const KEY = /^p_[\w-]{22}$/;
34
34
  const keys = z.array(z.string().regex(KEY)).max(100_000);
35
35
  const recordFile = z.object({
@@ -71,6 +71,12 @@ export class ComputerControl {
71
71
  });
72
72
  return { snapshot: this.changed(botId), owned: true, acquired: true };
73
73
  }
74
+ /** Whether this workspace lease is the one holding the wheel right now.
75
+ * Read-only: unlike `acquireLease`, a free computer is not taken. */
76
+ ownsLease(botId, controlLeaseId) {
77
+ const entry = this.entries.get(botId);
78
+ return entry?.heldSinceMs != null && entry.controlLeaseId === controlLeaseId;
79
+ }
74
80
  /** The person hands the wheel back. Also settles any open help request —
75
81
  * the waiting bot resumes from this one state change. */
76
82
  release(botId) {
@@ -388,6 +388,7 @@ const appConfigSchema = z.object({
388
388
  phone: z.enum(["ios", "android"]).optional(),
389
389
  }).optional(),
390
390
  mistral: z.object({ key: optionalText }).optional(),
391
+ cerebras: z.object({ key: optionalText }).optional(),
391
392
  xai: z.object({ key: optionalText, url: optionalText }).optional(),
392
393
  /** Anthropic API key for Claude Code billed per token, handed only to
393
394
  * Claude instances; `url` only for a proxy or a test double. Never a
@@ -884,6 +885,9 @@ export function loadConfig() {
884
885
  cfg.mistral = { ...cfg.mistral };
885
886
  if (process.env.MISTRAL_API_KEY !== undefined)
886
887
  cfg.mistral.key = process.env.MISTRAL_API_KEY;
888
+ cfg.cerebras = { ...cfg.cerebras };
889
+ if (process.env.CEREBRAS_API_KEY !== undefined)
890
+ cfg.cerebras.key = process.env.CEREBRAS_API_KEY;
887
891
  cfg.xai = { ...cfg.xai };
888
892
  if (process.env.XAI_API_KEY !== undefined)
889
893
  cfg.xai.key = process.env.XAI_API_KEY;
@@ -961,6 +965,7 @@ export function syncCredentialEnv(patch) {
961
965
  const secrets = [
962
966
  [patch.xai?.key, "XAI_API_KEY"],
963
967
  [patch.mistral?.key, "MISTRAL_API_KEY"],
968
+ [patch.cerebras?.key, "CEREBRAS_API_KEY"],
964
969
  [patch.anthropic?.key, "OMB_ANTHROPIC_API_KEY"],
965
970
  [patch.openaiCompat?.key, "OPENAI_COMPAT_API_KEY"],
966
971
  [patch.openai?.key, "OMB_OPENAI_API_KEY"],
@@ -1007,6 +1012,7 @@ export function syncCredentialEnv(patch) {
1007
1012
  export const WORKSPACE_CREDENTIAL_ENV = [
1008
1013
  "XAI_API_KEY",
1009
1014
  "MISTRAL_API_KEY",
1015
+ "CEREBRAS_API_KEY",
1010
1016
  "OMB_ANTHROPIC_API_KEY",
1011
1017
  "OMB_ANTHROPIC_API_URL",
1012
1018
  "OMB_HOSTED_MODEL_TOKEN",
@@ -1079,6 +1085,7 @@ export const PROVIDER_CREDENTIAL_ENV = [
1079
1085
  "OPENCODE_API_KEY",
1080
1086
  "XAI_API_KEY",
1081
1087
  "MISTRAL_API_KEY",
1088
+ "CEREBRAS_API_KEY",
1082
1089
  "CURSOR_API_KEY",
1083
1090
  "CURSOR_AUTH_TOKEN",
1084
1091
  ];
@@ -1111,7 +1118,7 @@ export function saveConfig(patch, options = {}) {
1111
1118
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
1112
1119
  if (storedProfiles.success)
1113
1120
  disk.browserProfiles = storedProfiles.data;
1114
- for (const key of ["xai", "anthropic", "mistral", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1121
+ for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1115
1122
  const section = checkedPatch[key];
1116
1123
  if (!section)
1117
1124
  continue;
@@ -1340,6 +1347,8 @@ export function instanceOwnsRouting(cfg, entry, routingDefaults) {
1340
1347
  || ownUrl(routingDefaults?.url || cfg.openaiCompat?.url || process.env.OPENAI_COMPAT_URL || "https://openrouter.ai/api/v1");
1341
1348
  case "mistral":
1342
1349
  return own(entry.environment?.MISTRAL_API_KEY) || ownUrl("https://api.mistral.ai/v1");
1350
+ case "cerebras":
1351
+ return own(entry.environment?.CEREBRAS_API_KEY) || ownUrl("https://api.cerebras.ai/v1");
1343
1352
  case "grok":
1344
1353
  return own(entry.environment?.XAI_API_KEY) || (own(config.apiKeyEnv) && config.apiKeyEnv !== "XAI_API_KEY")
1345
1354
  || ownUrl("https://api.x.ai/v1", cfg.xai?.url);
@@ -1357,6 +1366,8 @@ function injectedEnvironment(cfg, instanceId, driver) {
1357
1366
  const environment = new Map();
1358
1367
  if (driver === "mistral" && cfg.mistral?.key)
1359
1368
  environment.set("MISTRAL_API_KEY", cfg.mistral.key);
1369
+ if (driver === "cerebras" && cfg.cerebras?.key)
1370
+ environment.set("CEREBRAS_API_KEY", cfg.cerebras.key);
1360
1371
  if (driver === "grok" && cfg.xai?.key)
1361
1372
  environment.set("XAI_API_KEY", cfg.xai.key);
1362
1373
  // The workspace Anthropic key reaches Claude Code as the variable it
@@ -1453,6 +1464,7 @@ export function instanceConfigs(cfg) {
1453
1464
  computer: { driver: "boxAgent" },
1454
1465
  openaiCompat: { driver: "openai-compat" },
1455
1466
  mistral: { driver: "mistral" },
1467
+ cerebras: { driver: "cerebras" },
1456
1468
  ...API_KEY_FLEET,
1457
1469
  qwen: { driver: "qwenAgent" },
1458
1470
  hermes: { driver: "hermesAgent" },
@@ -1471,6 +1483,7 @@ export function instanceConfigs(cfg) {
1471
1483
  cursor: { driver: "cursorAgent" },
1472
1484
  openaiCompat: { driver: "openai-compat" },
1473
1485
  mistral: { driver: "mistral" },
1486
+ cerebras: { driver: "cerebras" },
1474
1487
  ...API_KEY_FLEET,
1475
1488
  ...CUSTOM_ONLY,
1476
1489
  };
@@ -82,7 +82,7 @@ export function poolLocalVmTarget(seat) {
82
82
  label: `pool-${seat}`,
83
83
  };
84
84
  }
85
- /** Only provisioning creates this durable directory; idle removal keeps it. */
85
+ /** Only provisioning creates this durable directory; idle shutdown keeps it. */
86
86
  export function localVmWorkspaceExists(target) {
87
87
  try {
88
88
  return lstatSync(target.workspaceDir, { throwIfNoEntry: false })?.isDirectory() === true;
@@ -269,6 +269,7 @@ function emptyStatus(platform, target) {
269
269
  desktopReady: false,
270
270
  desktop_error: null,
271
271
  create_supported: true,
272
+ resumable: false,
272
273
  ready: false,
273
274
  problem: "Install a supported container runtime first",
274
275
  image_ref: IMAGE,
@@ -283,21 +284,8 @@ function emptyStatus(platform, target) {
283
284
  viewer_url: target.viewerPort ? `http://127.0.0.1:${target.viewerPort}/vnc.html` : "",
284
285
  };
285
286
  }
286
- /** Whether a turn may recreate this Local VM itself instead of failing.
287
- *
288
- * True for exactly one state: the container is gone, and a plain `run` is all
289
- * that is needed to bring it back. That is what `LocalVmIdleTimer` leaves
290
- * behind — it removes an unused Local VM rather than pausing it — so a turn
291
- * arriving after an idle period should not have to send the person to App
292
- * Settings for a container the app itself deleted.
293
- *
294
- * Every other problem in `statusProblem` stays the person's call and returns
295
- * false here: no runtime, daemon down, image never prepared, `create_supported`
296
- * false, and any existing container — stale image, unmanaged, unsafe network,
297
- * security or persistence. A stopped container is excluded deliberately, since
298
- * `statusProblem` says this desktop image cannot safely resume and asks for a
299
- * recreate rather than a start.
300
- */
287
+ /** Recreate a missing desktop when its image and runtime are already prepared.
288
+ * This also recovers desktops deleted by older versions' idle cleanup. */
301
289
  export function localVmRecreatableOnDemand(status) {
302
290
  return Boolean(status.runtime)
303
291
  && status.daemonUp
@@ -305,12 +293,28 @@ export function localVmRecreatableOnDemand(status) {
305
293
  && status.container === "missing"
306
294
  && status.create_supported;
307
295
  }
296
+ /** Start only an existing, compatible desktop with the managed safety boundary.
297
+ * Fails closed on fields left "unknown" by a partial inspect, which
298
+ * `statusProblem` alone would let through. */
299
+ export function localVmResumable(status) {
300
+ return Boolean(status.runtime) && status.daemonUp && status.image && status.container === "stopped"
301
+ && existingContainerProblem(status) === null
302
+ && status.network === "loopback" && status.security === "hardened" && status.persistence === "durable";
303
+ }
304
+ /** What a turn may do on its own to bring this Local VM up, if anything. */
305
+ export function localVmWakeAction(status) {
306
+ if (localVmRecreatableOnDemand(status))
307
+ return "run";
308
+ if (localVmResumable(status))
309
+ return "start";
310
+ return null;
311
+ }
308
312
  /** Whether Auto may attach this Local VM without a person choosing it: the
309
- * desktop is ready, or its image is prepared and the container can simply be
310
- * recreated after idling away. Anything else — no runtime, daemon down, image
313
+ * desktop is ready, a compatible stopped desktop can be started, or its image
314
+ * is prepared and a missing container can be recreated. Anything else — no runtime, daemon down, image
311
315
  * never prepared, an unmanaged or unsafe container — stays the person's call. */
312
316
  export function autoLocalVmAttachable(status) {
313
- return status.ready === true || localVmRecreatableOnDemand(status);
317
+ return status.ready === true || localVmWakeAction(status) !== null;
314
318
  }
315
319
  function statusProblem(status) {
316
320
  if (!status.runtime)
@@ -324,6 +328,19 @@ function statusProblem(status) {
324
328
  }
325
329
  if (status.container === "missing")
326
330
  return "Create the Local VM";
331
+ const existing = existingContainerProblem(status);
332
+ if (existing)
333
+ return existing;
334
+ if (status.container === "stopped")
335
+ return "The Local VM is stopped; start it to continue";
336
+ if (status.desktop_error)
337
+ return `The Local VM desktop failed to start: ${status.desktop_error}`;
338
+ if (!status.desktopReady)
339
+ return "The Local VM started, but Cua Driver is not ready yet";
340
+ return null;
341
+ }
342
+ /** Problems with an existing container that only a recreate fixes. */
343
+ function existingContainerProblem(status) {
327
344
  if (!status.imageMatches)
328
345
  return "The existing Local VM uses an older desktop or Cua Driver; recreate it";
329
346
  if (!status.managed)
@@ -334,12 +351,6 @@ function statusProblem(status) {
334
351
  return "The existing Local VM is missing safety limits; recreate it";
335
352
  if (status.persistence === "unsafe")
336
353
  return "The existing Local VM is missing its durable folder; recreate it";
337
- if (status.container === "stopped")
338
- return "This desktop image cannot safely resume; recreate the Local VM";
339
- if (status.desktop_error)
340
- return `The Local VM desktop failed to start: ${status.desktop_error}`;
341
- if (!status.desktopReady)
342
- return "The Local VM started, but Cua Driver is not ready yet";
343
354
  return null;
344
355
  }
345
356
  /** Shared with the BYO-VPS backend (vps-computer.ts): both containers are
@@ -457,6 +468,7 @@ export async function containerComputerStatus(runner = sh, platform = process.pl
457
468
  const inspected = JSON.parse(stdout);
458
469
  const detail = inspected[0];
459
470
  status.container = detail?.State?.Running ? "running" : "stopped";
471
+ status.stopped_at = status.container === "stopped" ? detail?.State?.FinishedAt ?? null : null;
460
472
  status.network = dockerPortsAreLocal(detail?.HostConfig?.PortBindings) ? "loopback" : "unsafe";
461
473
  status.viewer_port = dockerViewerPort(detail?.NetworkSettings?.Ports, target.viewerPort);
462
474
  status.imageMatches =
@@ -530,6 +542,7 @@ export async function containerComputerStatus(runner = sh, platform = process.pl
530
542
  }
531
543
  status.problem = statusProblem(status);
532
544
  status.ready = status.problem === null;
545
+ status.resumable = localVmResumable(status);
533
546
  return status;
534
547
  }
535
548
  function loopback(address) {
@@ -597,8 +610,8 @@ function appleWorkspaceMountIsSafe(mounts, platform, expectedWorkspace) {
597
610
  * runtime-specific capability exception is Podman's Firefox sandbox chroot.
598
611
  * Callers also differ on restart policy — the VPS
599
612
  * container must survive a reboot nobody is watching ("unless-stopped"),
600
- * while the Local VM must NOT auto-resume: its desktop leaves a stale X lock
601
- * on stop, so a restarted container is a broken one. */
613
+ * while Local VM starts remain controlled by OMB's idle policy and turn
614
+ * lifecycle rather than a daemon restart policy. */
602
615
  export function dockerSecurityIsHardened(config, options = {}) {
603
616
  if (!config)
604
617
  return false;
@@ -727,10 +740,8 @@ export async function containerComputerAction(action, runner = sh, platform = pr
727
740
  if (action === "run" && !before.create_supported) {
728
741
  throw Object.assign(new Error(before.problem ?? "This runtime cannot create a per-bot Local VM"), { status: 409 });
729
742
  }
730
- if (action === "start") {
731
- throw Object.assign(new Error("This desktop image cannot safely resume; remove and recreate the Local VM"), {
732
- status: 409,
733
- });
743
+ if (action === "start" && !localVmResumable(before)) {
744
+ throw Object.assign(new Error(before.problem ?? "The Local VM is not stopped"), { status: 409 });
734
745
  }
735
746
  if (action === "stop" && before.container !== "running") {
736
747
  throw Object.assign(new Error("The Local VM is not running"), { status: 409 });
@@ -943,7 +954,7 @@ export function setupCommands(runtime, platform = process.platform, target = SHA
943
954
  run: runtime === "container" && target.key !== SHARED_LOCAL_VM_TARGET.key
944
955
  ? null
945
956
  : command(containerRunArgs(runtime, "CHANGE_ME", target)),
946
- start: null,
957
+ start: command(["start", target.containerName]),
947
958
  stop: command(["stop", target.containerName]),
948
959
  remove: command(["rm", runtime === "container" ? "--force" : "-f", target.containerName]),
949
960
  view: target.viewerPort ? `http://127.0.0.1:${target.viewerPort}/vnc.html` : "",
@@ -1,3 +1,4 @@
1
+ import { toolIdentity } from "../shared/tool-name.js";
1
2
  /** Evidence is observed, not inferred from a provider's brand. In particular,
2
3
  * OpenAI-compatible and Grok turns now expose structured tool activity too. */
3
4
  export function coverageForDriver(_driverKind, fullResults = false, observedTools = false) {
@@ -20,13 +21,19 @@ export function buildTurnDigest(input) {
20
21
  for (const m of input.activities) {
21
22
  if (m.kind !== "activity" || !m.tool?.itemId || m.turnId !== input.turnId)
22
23
  continue;
23
- const entry = byName.get(m.tool.name) ?? { name: fitBytes(m.tool.name, 160), count: 0, failed: 0 };
24
+ // by identity, not by the chip's title: a command-titled chip would
25
+ // otherwise be its own bucket and carry the unredacted command line into
26
+ // a row that is FTS-indexed and replayed into rebuilt context
27
+ const identity = toolIdentity(m.tool.name);
28
+ const entry = byName.get(identity) ?? { name: fitBytes(identity, 160), count: 0, failed: 0 };
24
29
  entry.count += 1;
25
30
  if (m.tool.ok === false)
26
31
  entry.failed += 1;
32
+ // the driver's summary is the bounded, redacted one-line command; the
33
+ // title is neither, so a shell bucket says what it ran only through this
27
34
  if (entry.sample === undefined && m.tool.summary)
28
35
  entry.sample = fitBytes(m.tool.summary.replace(/\s+/g, " "), 300);
29
- byName.set(m.tool.name, entry);
36
+ byName.set(identity, entry);
30
37
  }
31
38
  // busiest first; ties keep first-seen order (Map preserves insertion)
32
39
  const ranked = [...byName.values()].sort((a, b) => b.count - a.count);
@@ -78,6 +85,9 @@ function firstSentence(reply) {
78
85
  export function renderDigest(d) {
79
86
  const parts = ["[digest]"];
80
87
  if (d.tools.length) {
88
+ // every entry ends at "×N" or "(N failed)": the phones split the tools
89
+ // part on exactly that (ios DigestSummary, android TurnDigest), so a
90
+ // shell bucket's sample stays in the stored digest, not in this line
81
91
  const tools = d.tools.map((t) => `${t.name} ×${t.count}${t.failed ? ` (${t.failed} failed)` : ""}`).join(", ");
82
92
  parts.push(`tools: ${tools}${d.toolsDropped ? ` +${d.toolsDropped} more` : ""}${d.hookCoverage === "preview" ? " (from tool previews)" : ""}`);
83
93
  }
@@ -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.
@@ -87,8 +88,13 @@ const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 3
87
88
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
88
89
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
89
90
  /** ACP agents may compact their own history without telling the client;
90
- * 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. */
91
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;
92
98
  // Read lazily (not at import) so a test can shorten the window. Unlike the
93
99
  // setup calls above, session/prompt legitimately streams for minutes, so a
94
100
  // wall-clock deadline would false-positive: this guard only trips when the
@@ -96,13 +102,23 @@ const ACP_PROMPT_RE_ANCHOR_TURNS = 8;
96
102
  // streams thought chunks, then goes silent forever and never resolves). 0
97
103
  // disables the guard, restoring the pre-fix "hang until the user cancels"
98
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;
99
114
  const promptIdleTimeoutMs = () => {
100
115
  const raw = process.env.OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS;
101
116
  if (raw === undefined)
102
- return 180_000;
117
+ return DEFAULT_ACP_PROMPT_IDLE_MS;
103
118
  const ms = Number(raw);
104
119
  return Number.isFinite(ms) && ms > 0 ? ms : 0;
105
120
  };
121
+ const formatQuietLimit = (ms) => ms >= 120_000 && ms % 60_000 === 0 ? `${ms / 60_000} min` : `${Math.round(ms / 1000)} s`;
106
122
  /** Keep a turn's set of running tool calls in step with the agent's
107
123
  * `tool_call` / `tool_call_update` notifications: a call is running from
108
124
  * the first update that is not terminal until one that is. A `tool_call`
@@ -495,6 +511,101 @@ export function createAcpDriver(support) {
495
511
  }
496
512
  return servers;
497
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
+ };
498
609
  /** The one completion path for a turn: the prompt result, a crashed
499
610
  * child, an unanswered cancel, or an rpc error the turn body throws.
500
611
  * The child is NOT killed here — a clean settle leaves it pooled for
@@ -505,6 +616,7 @@ export function createAcpDriver(support) {
505
616
  return;
506
617
  current.state.settled = true;
507
618
  current.acknowledge();
619
+ current.stopQuietWatch?.();
508
620
  if (current.interruptTimer)
509
621
  clearTimeout(current.interruptTimer);
510
622
  for (const finish of current.asks.values())
@@ -597,7 +709,7 @@ export function createAcpDriver(support) {
597
709
  return;
598
710
  }
599
711
  rpcPending.delete(id);
600
- const error = new Error(idleMessage ?? `${method} stopped responding`);
712
+ const error = new Error((typeof idleMessage === "function" ? idleMessage() : idleMessage) ?? `${method} stopped responding`);
601
713
  Object.assign(error, { acpPromptStall: true });
602
714
  reject(error);
603
715
  }, idleMs);
@@ -616,9 +728,13 @@ export function createAcpDriver(support) {
616
728
  });
617
729
  send({ jsonrpc: "2.0", id, method, params });
618
730
  });
731
+ let lastInboundAt = Date.now();
619
732
  const acp = {
620
733
  send,
621
734
  request,
735
+ get lastInboundAt() { return lastInboundAt; },
736
+ touch: () => { for (const p of rpcPending.values())
737
+ p.armIdle(); },
622
738
  failAll: (error) => {
623
739
  for (const p of rpcPending.values()) {
624
740
  if (p.timer)
@@ -916,12 +1032,26 @@ export function createAcpDriver(support) {
916
1032
  }
917
1033
  break;
918
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
+ }
919
1048
  }
920
1049
  };
921
1050
  const session = {
922
1051
  child,
923
1052
  acp,
924
1053
  launch,
1054
+ env,
925
1055
  cwd,
926
1056
  contractKey,
927
1057
  sessionKey: null,
@@ -957,6 +1087,7 @@ export function createAcpDriver(support) {
957
1087
  appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
958
1088
  // Inbound traffic proves the child is alive and making progress,
959
1089
  // so every idle deadline restarts; only total silence trips it.
1090
+ lastInboundAt = Date.now();
960
1091
  for (const p of rpcPending.values())
961
1092
  p.armIdle();
962
1093
  if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
@@ -1159,7 +1290,7 @@ export function createAcpDriver(support) {
1159
1290
  // respawns the child, every wire call must reach the live record, so
1160
1291
  // nothing captures the connection off it.
1161
1292
  const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
1162
- 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 };
1163
1294
  let acknowledge = () => { };
1164
1295
  let rejectStartup = (_error) => { };
1165
1296
  const startupAck = turn.startupRecovery ? new Promise((resolve, reject) => {
@@ -1227,6 +1358,7 @@ export function createAcpDriver(support) {
1227
1358
  asks,
1228
1359
  runningTools: new Set(),
1229
1360
  interruptTimer: null,
1361
+ stopQuietWatch: null,
1230
1362
  flushAssistantText,
1231
1363
  receiveModelVariants,
1232
1364
  };
@@ -1252,6 +1384,7 @@ export function createAcpDriver(support) {
1252
1384
  emit({ ...base(threadId, turnId), type: "turn.started" });
1253
1385
  session.current = current;
1254
1386
  (async () => {
1387
+ let pendingSplitReceipt = null;
1255
1388
  try {
1256
1389
  // The handshake is paid once per process, not once per turn. It
1257
1390
  // is a function so the establishment retry below can pay it
@@ -1512,12 +1645,15 @@ export function createAcpDriver(support) {
1512
1645
  // adapter call) keeps the legacy full-prompt shape.
1513
1646
  const halves = promptHalves(turn);
1514
1647
  let promptInput = promptTurn;
1515
- let pendingSplitReceipt = null;
1516
1648
  if (halves.stable !== null) {
1517
1649
  const receiptKey = JSON.stringify([threadId, sessionId]);
1518
- 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);
1519
1655
  promptInput = { ...promptTurn, system: "", text: composed.text };
1520
- pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt };
1656
+ pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt, previous: previousReceipt };
1521
1657
  }
1522
1658
  const text = support.buildPromptText
1523
1659
  ? support.buildPromptText(promptInput)
@@ -1538,13 +1674,32 @@ export function createAcpDriver(support) {
1538
1674
  state.promptSent = true;
1539
1675
  acknowledge();
1540
1676
  const promptIdleMs = promptIdleTimeoutMs();
1541
- 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. ` +
1542
- "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
+ });
1543
1684
  if (pendingSplitReceipt) {
1544
1685
  // session/prompt resolving is the acceptance boundary: a
1545
1686
  // rejected prompt leaves the receipt unwritten, so the next
1546
- // turn redelivers what this one never received.
1547
- 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
+ }
1548
1703
  }
1549
1704
  // opencode 1.18.18 reports usage at the result root; grok and
1550
1705
  // gemini put it under _meta. Read both rather than lose the count.
@@ -1597,6 +1752,11 @@ export function createAcpDriver(support) {
1597
1752
  }
1598
1753
  }
1599
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
+ }
1600
1760
  if (!state.settled) {
1601
1761
  const message = e instanceof Error ? e.message : String(e);
1602
1762
  const code = support.classifyError?.(e);
@@ -30,9 +30,21 @@ export function resolveCursorAcpModelId(available, wanted) {
30
30
  const exact = ids.find((m) => m.modelId.toLowerCase() === want);
31
31
  if (exact)
32
32
  return exact.modelId;
33
- const byBase = ids.find((m) => base(m.modelId) === want);
33
+ // A base can be advertised several times with different parameters, e.g.
34
+ // `composer-2.5[fast=false]` and `composer-2.5[fast=true]`. The plain slug
35
+ // means Standard, so prefer the non-fast variant; a `-fast` slug has no base
36
+ // of its own and means the fast=true variant of the base without the suffix.
37
+ const isFast = (id) => /[[,]\s*fast\s*=\s*true\s*[\],]/i.test(id);
38
+ const sameBase = ids.filter((m) => base(m.modelId) === want);
39
+ const byBase = sameBase.find((m) => !isFast(m.modelId)) ?? sameBase[0];
34
40
  if (byBase)
35
41
  return byBase.modelId;
42
+ if (want.endsWith("-fast")) {
43
+ const stem = want.slice(0, -"-fast".length);
44
+ const fast = ids.find((m) => base(m.modelId) === stem && isFast(m.modelId));
45
+ if (fast)
46
+ return fast.modelId;
47
+ }
36
48
  const byName = ids.find((m) => (m.name ?? "").trim().toLowerCase() === want);
37
49
  if (byName)
38
50
  return byName.modelId;