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
@@ -1,26 +1,31 @@
1
1
  // The maintained OpenCode CLI through its ACP stdio interface. OpenCode is
2
2
  // the harness; Zen, Go, OpenRouter, and user-configured/local providers are
3
3
  // models discovered from that harness rather than separate OpenMaus drivers.
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execFile } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
- import { join } from "node:path";
7
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
8
+ import { ATTACHMENTS_DIR } from "../../attachments.js";
9
+ import { cloudHomeConfigured } from "../../cloud-home.js";
10
+ import { DATA_DIR } from "../../config.js";
11
+ import { hostedWorkspaceConfigured } from "../../enterprise.js";
7
12
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
8
- import { createAcpDriver } from "./core.js";
13
+ import { createAcpDriver, offeredModels } from "./core.js";
9
14
  import { titleCaseModelId } from "../../contracts.js";
10
- import { execCli } from "../../procs.js";
11
- const STATIC_MODELS = {
12
- default: "opencode/x-preview-f-free",
13
- options: [
14
- {
15
- id: "opencode/x-preview-f-free",
16
- label: "Zen · Ox Alpha Free",
17
- contextWindow: 1_000_000,
18
- },
19
- ],
20
- };
21
- let lastSuccessfulCatalog = null;
22
- const MODEL_PROBE_TTL_MS = 30_000;
23
- const modelProbeCache = new Map();
15
+ import { execCli, killCliTree, spawnCli } from "../../procs.js";
16
+ /** Nothing is offered until OpenCode itself answers. An invented default is
17
+ * exactly what broke bots when Zen retired `x-preview-f-free`. */
18
+ const NO_MODELS = { default: "", options: [] };
19
+ /** The last catalog each OpenCode binary reported, so a failed refresh keeps
20
+ * a usable picker and the setup check needs no process of its own. */
21
+ const lastSuccessfulCatalog = new Map();
22
+ /** Zen prices from `models --verbose` (V1): cost 0 means free. V2 prints no
23
+ * metadata, so an id ending in `-free` is the fallback signal. */
24
+ const freeModels = new Set();
25
+ const paidModels = new Set();
26
+ const DISCOVERY_TIMEOUT_MS = 20_000;
27
+ const DISCOVERY_PROBE_TTL_MS = 30_000;
28
+ const discoveryProbeCache = new Map();
24
29
  function labelForModel(id) {
25
30
  return titleCaseModelId(id, /[-_.]+/);
26
31
  }
@@ -53,14 +58,39 @@ function localModelRecord(record) {
53
58
  return false;
54
59
  }
55
60
  }
56
- /** Parse the authoritative inventory printed by the installed OpenCode CLI.
61
+ /** Whether a model costs nothing to run: Zen's own price when `models
62
+ * --verbose` reported one, else the `-free` suffix Zen gives free models. */
63
+ export function isFreeOpenCodeModel(id) {
64
+ if (freeModels.has(id))
65
+ return true;
66
+ if (paidModels.has(id))
67
+ return false;
68
+ return /(?:^|[-_/])free$/iu.test(id);
69
+ }
70
+ /** The model OpenMaus runs when nobody chose one, or when the chosen one is
71
+ * gone. OpenCode's own pick (`current`) wins when it is free. Otherwise a
72
+ * free model does: OpenCode 2 with a key picks a paid model on its own, and
73
+ * a silent switch to per-token billing is not ours to make. Only an
74
+ * all-paid catalog (the person's own providers) falls back to OpenCode's
75
+ * pick. */
76
+ export function preferredOpenCodeModel(offered, current) {
77
+ if (current && offered.includes(current) && isFreeOpenCodeModel(current))
78
+ return current;
79
+ const free = offered.filter(isFreeOpenCodeModel);
80
+ return free.find((id) => id.startsWith("opencode/"))
81
+ ?? free[0]
82
+ ?? (current && offered.includes(current) ? current : offered[0] ?? "");
83
+ }
84
+ /** Parse the metadata printed by `opencode models --verbose` (V1 only).
57
85
  *
58
- * `models --verbose` is a sequence of `provider/model` header lines followed
86
+ * The output is a sequence of `provider/model` header lines, each followed
59
87
  * by one JSON object. Model IDs can themselves contain `/` (OpenRouter), so
60
88
  * only the first separator identifies the provider. Older CLIs may print just
61
89
  * the headers; those still produce a usable catalog without metadata. */
62
- export function parseOpenCodeModelsOutput(stdout) {
90
+ function parseOpenCodeModels(stdout) {
63
91
  const options = [];
92
+ const free = [];
93
+ const paid = [];
64
94
  const seen = new Set();
65
95
  let slug = null;
66
96
  let jsonLines = [];
@@ -85,6 +115,12 @@ export function parseOpenCodeModelsOutput(stdout) {
85
115
  }
86
116
  if (record.status === "deprecated")
87
117
  return;
118
+ const cost = record.cost && typeof record.cost === "object" && !Array.isArray(record.cost)
119
+ ? record.cost
120
+ : null;
121
+ if (cost && typeof cost.input === "number" && typeof cost.output === "number") {
122
+ (cost.input === 0 && cost.output === 0 ? free : paid).push(slug);
123
+ }
88
124
  const name = typeof record.name === "string" && record.name.trim()
89
125
  ? record.name.trim()
90
126
  : labelForModel(model);
@@ -119,56 +155,322 @@ export function parseOpenCodeModelsOutput(stdout) {
119
155
  jsonLines.push(line);
120
156
  }
121
157
  flush();
122
- if (!options.length)
158
+ return { options, free, paid };
159
+ }
160
+ function rememberPrices(parsed) {
161
+ for (const id of parsed.free) {
162
+ freeModels.add(id);
163
+ paidModels.delete(id);
164
+ }
165
+ for (const id of parsed.paid) {
166
+ paidModels.add(id);
167
+ freeModels.delete(id);
168
+ }
169
+ }
170
+ /** The `models --verbose` inventory as a catalog (V1). Discovery prefers the
171
+ * ACP session's own list and uses this only for metadata; it remains the
172
+ * catalog when the ACP probe cannot answer. */
173
+ export function parseOpenCodeModelsOutput(stdout) {
174
+ const parsed = parseOpenCodeModels(stdout);
175
+ if (!parsed.options.length)
123
176
  return null;
124
- const preferred = options.find((option) => option.id === STATIC_MODELS.default);
125
- return { default: (preferred ?? options[0]).id, options };
177
+ rememberPrices(parsed);
178
+ return { default: preferredOpenCodeModel(parsed.options.map((option) => option.id)), options: parsed.options };
126
179
  }
127
- function runOpenCodeModels(cli, environment, verbose) {
180
+ /** The catalog OpenCode itself offers a session, from the `model` select in
181
+ * session/new's configOptions: the exact ids `session/set_config_option`
182
+ * accepts, in the exact environment of the process. Same list as `opencode
183
+ * models` on V1 and the only reliable one on V2. */
184
+ export function catalogFromOpenCodeSession(result) {
185
+ const configOptions = result?.configOptions;
186
+ const model = Array.isArray(configOptions)
187
+ ? configOptions.find((option) => option?.id === "model" && option?.type === "select")
188
+ : null;
189
+ const ids = offeredModels(result, "model");
190
+ if (!model || !ids.length)
191
+ return null;
192
+ const names = new Map();
193
+ const visit = (entries) => {
194
+ if (!Array.isArray(entries))
195
+ return;
196
+ for (const entry of entries) {
197
+ if (typeof entry?.value === "string" && typeof entry.name === "string")
198
+ names.set(entry.value, entry.name);
199
+ else if (Array.isArray(entry?.options))
200
+ visit(entry.options);
201
+ }
202
+ };
203
+ visit(model.options);
204
+ const options = [...new Set(ids)].filter(validModelSlug).map((id) => {
205
+ const separator = id.indexOf("/");
206
+ // V1 names "OpenCode Zen/Big Pickle", V2 "opencode/Big Pickle": the
207
+ // provider comes from the id either way.
208
+ const raw = names.get(id)?.trim() ?? "";
209
+ const name = (raw.includes("/") ? raw.slice(raw.indexOf("/") + 1) : raw).trim() || labelForModel(id.slice(separator + 1));
210
+ return { id, label: `${providerLabel(id.slice(0, separator))} · ${name}` };
211
+ });
212
+ return options.length
213
+ ? { options, current: typeof model.currentValue === "string" ? model.currentValue : null }
214
+ : null;
215
+ }
216
+ function runOpenCodeModelsVerbose(cli, environment) {
128
217
  return new Promise((resolve, reject) => {
129
- execCli(cli, ["models", ...(verbose ? ["--verbose"] : [])], { timeout: 20_000, maxBuffer: 8 * 1024 * 1024, env: environment }, (error, stdout, stderr) => {
218
+ execCli(cli, ["models", "--verbose"], { timeout: 20_000, maxBuffer: 8 * 1024 * 1024, env: environment }, (error, stdout, stderr) => {
130
219
  if (error) {
131
- reject(new Error(stderr?.trim() || error.message, { cause: error }));
220
+ reject(Object.assign(new Error(stderr?.trim() || error.message, { cause: error }), {
221
+ timedOut: error.killed === true,
222
+ }));
132
223
  return;
133
224
  }
134
225
  resolve(stdout);
135
226
  });
136
227
  });
137
228
  }
229
+ function runGit(directory, args) {
230
+ // The person's git settings stay out: no signing prompt, no hooks, no
231
+ // repository named by a GIT_* variable.
232
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
233
+ return new Promise((resolveRun, rejectRun) => {
234
+ execFile("git", [
235
+ "-c", "user.name=OpenMaus", "-c", "user.email=model-discovery@openmaus.invalid",
236
+ "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${join(directory, ".git", "no-hooks")}`,
237
+ ...args,
238
+ ], { cwd: directory, env: { ...env, GIT_TERMINAL_PROMPT: "0" }, timeout: 10_000, windowsHide: true }, (error) => {
239
+ if (error)
240
+ rejectRun(error);
241
+ else
242
+ resolveRun();
243
+ });
244
+ });
245
+ }
246
+ let discoveryFolder = null;
247
+ /** A folder OpenMaus owns for catalog probes, made its own git project once.
248
+ * OpenCode records every session, empty ones included, under the project of
249
+ * its working folder, and every folder outside git shares one global project:
250
+ * `opencode run --continue` in any of them would resume OpenMaus's empty probe
251
+ * instead of the person's last session. Without git the probe still works and
252
+ * its session lands in the global list, as before. */
253
+ function discoveryDirectory() {
254
+ discoveryFolder ??= (async () => {
255
+ const directory = join(DATA_DIR, "providers", "opencode", "discovery");
256
+ mkdirSync(directory, { recursive: true });
257
+ const marker = join(directory, ".openmaus-project");
258
+ if (!existsSync(marker)) {
259
+ try {
260
+ await runGit(directory, ["init", "-q"]);
261
+ await runGit(directory, ["commit", "-q", "--allow-empty", "--no-verify", "-m", "OpenMaus model discovery"]);
262
+ writeFileSync(marker, "OpenCode files this folder's sessions under its own project.\n");
263
+ }
264
+ catch {
265
+ // No git: probes run here all the same.
266
+ }
267
+ }
268
+ return directory;
269
+ })();
270
+ return discoveryFolder;
271
+ }
272
+ /** Open one ACP session on the binary and environment a turn uses, and
273
+ * return what session/new answered. No prompt is sent and the process is
274
+ * stopped as soon as the answer arrives. */
275
+ export async function probeOpenCodeSession(cli, environment, folder, timeoutMs = DISCOVERY_TIMEOUT_MS) {
276
+ const cwd = folder ?? await discoveryDirectory();
277
+ return new Promise((resolve, reject) => {
278
+ let child;
279
+ try {
280
+ child = spawnCli(cli, ["acp"], { cwd, env: environment, stdio: ["pipe", "pipe", "pipe"] });
281
+ }
282
+ catch (error) {
283
+ reject(error);
284
+ return;
285
+ }
286
+ let settled = false;
287
+ let buffer = "";
288
+ let nextId = 0;
289
+ const pending = new Map();
290
+ const write = (message) => {
291
+ try {
292
+ child.stdin.write(`${JSON.stringify(message)}\n`);
293
+ }
294
+ catch { }
295
+ };
296
+ const finish = (error, value) => {
297
+ if (settled)
298
+ return;
299
+ settled = true;
300
+ clearTimeout(timer);
301
+ try {
302
+ child.stdin.end();
303
+ }
304
+ catch { }
305
+ void killCliTree(child);
306
+ if (error)
307
+ reject(error);
308
+ else
309
+ resolve(value);
310
+ };
311
+ const timer = setTimeout(() => finish(Object.assign(new Error("OpenCode did not list its models in time"), { timedOut: true })), timeoutMs);
312
+ timer.unref?.();
313
+ child.on("error", (error) => finish(error));
314
+ child.on("close", (code) => finish(new Error(`OpenCode exited ${code} while listing its models`)));
315
+ child.stderr.resume();
316
+ child.stdout.setEncoding("utf8");
317
+ child.stdout.on("data", (chunk) => {
318
+ buffer += chunk;
319
+ let newline;
320
+ while ((newline = buffer.indexOf("\n")) !== -1) {
321
+ const line = buffer.slice(0, newline);
322
+ buffer = buffer.slice(newline + 1);
323
+ let message;
324
+ try {
325
+ message = JSON.parse(line);
326
+ }
327
+ catch {
328
+ continue;
329
+ }
330
+ if (message?.id !== undefined && typeof message.method === "string") {
331
+ // A probe answers no request: refuse rather than leave it hanging.
332
+ write({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "method not found" } });
333
+ continue;
334
+ }
335
+ const waiter = typeof message?.id === "number" ? pending.get(message.id) : undefined;
336
+ if (!waiter)
337
+ continue;
338
+ pending.delete(message.id);
339
+ if (message.error) {
340
+ waiter.reject(Object.assign(new Error(String(message.error.message ?? "ACP request failed")), {
341
+ code: message.error.code,
342
+ data: message.error.data,
343
+ }));
344
+ }
345
+ else
346
+ waiter.resolve(message.result);
347
+ }
348
+ });
349
+ const request = (method, params) => new Promise((resolveRequest, rejectRequest) => {
350
+ const id = ++nextId;
351
+ pending.set(id, { resolve: resolveRequest, reject: rejectRequest });
352
+ write({ jsonrpc: "2.0", id, method, params });
353
+ });
354
+ void (async () => {
355
+ await request("initialize", {
356
+ protocolVersion: 1,
357
+ clientInfo: { name: "openmausbot", version: "0.0.0" },
358
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
359
+ });
360
+ return request("session/new", { cwd, mcpServers: [] });
361
+ })().then((result) => finish(null, result), (error) => finish(error));
362
+ });
363
+ }
138
364
  /** Ask the same OpenCode binary that will run ACP for its effective catalog.
139
- * This automatically includes Zen, Go, other connected providers, custom
140
- * config, and anonymous free models with the exact IDs the session accepts. */
365
+ *
366
+ * The list comes from an ACP session/new in the turn's own environment: it
367
+ * is what the session will accept, on OpenCode 1 and 2 alike. `models
368
+ * --verbose` (OpenCode 1 only; 2 rejects the flag) runs alongside for
369
+ * metadata the session does not carry: prices, context windows, reasoning
370
+ * variants, and which models are local. Plain `opencode models` is not a
371
+ * fallback: on OpenCode 2 it answers from a shared background service that
372
+ * keeps whatever environment it started with, and its first answer after
373
+ * that service starts is empty. */
141
374
  export async function discoverOpenCodeModels(environment, cli = "opencode") {
142
- try {
143
- const catalog = parseOpenCodeModelsOutput(await runOpenCodeModels(cli, environment, true));
144
- if (!catalog)
145
- throw new Error("OpenCode returned no usable models");
146
- lastSuccessfulCatalog = catalog;
147
- return catalog;
375
+ const probeSession = () => probeOpenCodeSession(cli, environment);
376
+ const readMetadata = () => runOpenCodeModelsVerbose(cli, environment).then(parseOpenCodeModels);
377
+ let [session, verbose] = await Promise.allSettled([probeSession(), readMetadata()]);
378
+ // Two OpenCode processes starting together in a home that has never run
379
+ // OpenCode race to create its database, and one of them fails (seen 1 in 8
380
+ // fresh homes). Once is enough to lose Zen's prices, which is what keeps a
381
+ // paid model from becoming the default, so a failed half runs again alone.
382
+ // A half that timed out is not retried: a wedged CLI must not double the
383
+ // wait at startup.
384
+ const retry = (result) => result.status === "rejected" && result.reason?.timedOut !== true;
385
+ if (retry(session))
386
+ [session] = await Promise.allSettled([probeSession()]);
387
+ if (retry(verbose))
388
+ [verbose] = await Promise.allSettled([readMetadata()]);
389
+ const metadata = verbose.status === "fulfilled" ? verbose.value : null;
390
+ if (metadata)
391
+ rememberPrices(metadata);
392
+ const live = session.status === "fulfilled" ? catalogFromOpenCodeSession(session.value) : null;
393
+ let catalog = null;
394
+ if (live) {
395
+ const details = new Map((metadata?.options ?? []).map((option) => [option.id, option]));
396
+ const options = live.options.map((option) => ({ ...option, ...details.get(option.id), id: option.id }));
397
+ catalog = { default: preferredOpenCodeModel(options.map((option) => option.id), live.current), options };
148
398
  }
149
- catch {
150
- return lastSuccessfulCatalog ?? STATIC_MODELS;
399
+ else if (metadata?.options.length) {
400
+ catalog = { default: preferredOpenCodeModel(metadata.options.map((option) => option.id)), options: metadata.options };
151
401
  }
402
+ if (!catalog)
403
+ return lastSuccessfulCatalog.get(cli) ?? NO_MODELS;
404
+ lastSuccessfulCatalog.set(cli, catalog);
405
+ return catalog;
406
+ }
407
+ /** Whether this OpenCode can run a turn at all: it offers at least one
408
+ * model. Zen's free models need no login, so a working CLI almost always
409
+ * can. Answered from the last discovery when there is one. */
410
+ export async function canRunOpenCode(environment, cli, discover = discoverOpenCodeModels) {
411
+ if (lastSuccessfulCatalog.get(cli)?.options.length)
412
+ return true;
413
+ const cached = discoveryProbeCache.get(cli);
414
+ if (cached && cached.expiresAt > Date.now())
415
+ return cached.result;
416
+ const entry = { expiresAt: Number.POSITIVE_INFINITY, result: Promise.resolve(false) };
417
+ entry.result = discover(environment, cli)
418
+ .then((catalog) => catalog.options.length > 0, () => false)
419
+ .finally(() => {
420
+ entry.expiresAt = Date.now() + DISCOVERY_PROBE_TTL_MS;
421
+ });
422
+ discoveryProbeCache.set(cli, entry);
423
+ return entry.result;
152
424
  }
153
425
  export function resetOpenCodeModelCache() {
154
- lastSuccessfulCatalog = null;
155
- modelProbeCache.clear();
426
+ discoveryFolder = null;
427
+ lastSuccessfulCatalog.clear();
428
+ discoveryProbeCache.clear();
429
+ freeModels.clear();
430
+ paidModels.clear();
156
431
  }
157
432
  /** Compatibility export for older tests/imports while the product migrates
158
433
  * from the Go-only name. */
159
434
  export const resetOpenCodeGoModelCache = resetOpenCodeModelCache;
160
- const stripForeignProviderKeys = (env) => {
161
- for (const key of [
162
- "OPENAI_API_KEY",
163
- "ANTHROPIC_API_KEY",
164
- "GEMINI_API_KEY",
165
- "GOOGLE_API_KEY",
166
- "XAI_API_KEY",
167
- "KIMI_API_KEY",
168
- "MOONSHOT_API_KEY",
169
- ])
435
+ /** Provider keys OpenCode reads from its environment, as it does in a
436
+ * terminal: with ANTHROPIC_API_KEY set, `opencode` lists Anthropic's models.
437
+ * Keys OpenMaus saves for another engine (xAI, Mistral, the workspace
438
+ * Anthropic key) are workspace credentials under other names and never
439
+ * ride along. */
440
+ export const OPENCODE_PROVIDER_ENV = [
441
+ "ANTHROPIC_API_KEY",
442
+ "OPENAI_API_KEY",
443
+ "GEMINI_API_KEY",
444
+ "GOOGLE_API_KEY",
445
+ "KIMI_API_KEY",
446
+ "MOONSHOT_API_KEY",
447
+ "MINIMAX_API_KEY",
448
+ ];
449
+ /** Whether OpenCode may read provider keys from the server's own
450
+ * environment. Only when that environment is the person's own shell: not on a
451
+ * Cloud home, a hosted team workspace or an organisation-managed desktop, and
452
+ * not on a server whose sign-in list lets other people in, where every
453
+ * member's bot would otherwise run on, and bill, the operator's key. */
454
+ export function openCodeProviderKeysAllowed(state) {
455
+ return !state.cloudHome && !state.hostedWorkspace && !state.organisationManaged && !state.sharedSignIn;
456
+ }
457
+ let providerKeysAllowed = () => openCodeProviderKeysAllowed({
458
+ cloudHome: cloudHomeConfigured(),
459
+ hostedWorkspace: hostedWorkspaceConfigured(),
460
+ organisationManaged: false,
461
+ sharedSignIn: false,
462
+ });
463
+ /** The server sets this before any driver is created, and narrows it with
464
+ * Company enrollment and its sign-in list. */
465
+ export function setOpenCodeProviderKeyPolicy(allowed) {
466
+ providerKeysAllowed = allowed;
467
+ }
468
+ function withholdProviderKeysWhenManaged(env) {
469
+ if (providerKeysAllowed())
470
+ return;
471
+ for (const key of OPENCODE_PROVIDER_ENV)
170
472
  delete env[key];
171
- };
473
+ }
172
474
  function opencodeConfigDir(env) {
173
475
  const home = env.HOME || env.USERPROFILE || homedir();
174
476
  return join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode");
@@ -281,41 +583,232 @@ function hasStoredOpenCodeAuth(env) {
281
583
  }
282
584
  });
283
585
  }
284
- export async function canListOpenCodeModels(env, cli, runModels = runOpenCodeModels) {
285
- const cached = modelProbeCache.get(cli);
286
- if (cached && cached.expiresAt > Date.now())
287
- return cached.result;
288
- const entry = {
289
- expiresAt: Number.POSITIVE_INFINITY,
290
- result: Promise.resolve(false),
586
+ /** Whether `path` is `folder` itself or somewhere inside it. */
587
+ function withinFolder(path, folder) {
588
+ const rest = relative(resolvePath(folder), resolvePath(path));
589
+ return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest));
590
+ }
591
+ /** Whether a turn works in one of OpenMaus's own folders: a bot's shared
592
+ * folder or a conversation's private one (server/workspace.ts). Everything in
593
+ * them was written by bots, so none of it may become OpenCode configuration. */
594
+ export function openMausOwnsWorkingFolder(cwd) {
595
+ return [join(DATA_DIR, "workspaces"), join(DATA_DIR, "task-workspaces")].some((root) => withinFolder(cwd, root) && resolvePath(cwd) !== resolvePath(root));
596
+ }
597
+ /** Folders OpenMaus owns that an OpenCode bot may use outside its working
598
+ * folder without an approval card: the attachments people send (every turn
599
+ * that read one stalled on a card) and this bot's own shared folder, which
600
+ * holds the memory its prompt points at.
601
+ *
602
+ * Never a folder at or above the turn's working folder. OpenCode reads
603
+ * opencode.json, .opencode/ and AGENTS.md from every folder above its working
604
+ * folder, so a file planted there would configure the bot's later
605
+ * conversations: one prompt-injected turn could allow every folder for good.
606
+ * That is also why earlier conversations (task-workspaces/<bot>, the parent of
607
+ * every conversation's folder) still ask.
608
+ *
609
+ * Accepted: OpenCode's folder rule covers writes as well as reads, and it
610
+ * allows edits and shell by default, so a bot can also change or delete files
611
+ * in the attachments folder, which holds every conversation's uploads under
612
+ * random names. Its edit rules match paths relative to the project and its
613
+ * shell rules match command text, so neither can fence one folder reliably. */
614
+ export function openCodeOwnedDirectories(botId, cwd) {
615
+ const directories = [ATTACHMENTS_DIR];
616
+ // One path segment only: a bot id never names a folder outside its own.
617
+ if (botId && /^[\w-][\w.-]*$/u.test(botId) && botId !== "..")
618
+ directories.push(join(DATA_DIR, "workspaces", botId));
619
+ return cwd ? directories.filter((directory) => !withinFolder(cwd, directory)) : directories;
620
+ }
621
+ /** JSON with comments and trailing commas, as OpenCode reads its config. */
622
+ function parseJsonc(text) {
623
+ let plain = "";
624
+ let inString = false;
625
+ for (let index = 0; index < text.length; index += 1) {
626
+ const character = text[index];
627
+ if (inString) {
628
+ plain += character;
629
+ if (character === "\\")
630
+ plain += text[++index] ?? "";
631
+ else if (character === '"')
632
+ inString = false;
633
+ }
634
+ else if (character === '"') {
635
+ inString = true;
636
+ plain += character;
637
+ }
638
+ else if (character === "/" && text[index + 1] === "/") {
639
+ while (index < text.length && text[index] !== "\n")
640
+ index += 1;
641
+ plain += "\n";
642
+ }
643
+ else if (character === "/" && text[index + 1] === "*") {
644
+ index += 2;
645
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/"))
646
+ index += 1;
647
+ index += 1;
648
+ }
649
+ else
650
+ plain += character;
651
+ }
652
+ try {
653
+ return JSON.parse(plain.replace(/,(\s*[}\]])/gu, "$1"));
654
+ }
655
+ catch {
656
+ return undefined;
657
+ }
658
+ }
659
+ function readText(path) {
660
+ try {
661
+ return readFileSync(path, "utf8");
662
+ }
663
+ catch {
664
+ return undefined;
665
+ }
666
+ }
667
+ /** The folder rule the person's own OpenCode config ends with, when it is a
668
+ * single action ("deny", "ask" or "allow") rather than a folder map.
669
+ * OPENCODE_PERMISSION is merged over the config and an object replaces a
670
+ * string, so without this OpenMaus's folder list would quietly turn a "deny"
671
+ * into OpenCode's default "ask". A folder map needs nothing: the two maps
672
+ * merge. Sources in OpenCode's own order: global config, OPENCODE_CONFIG,
673
+ * project files from the root down (unless project config is off), the
674
+ * .opencode folders, then OPENCODE_CONFIG_CONTENT. */
675
+ export function configuredOpenCodeFolderAction(env, cwd, projectConfig) {
676
+ let rule;
677
+ const apply = (raw) => {
678
+ if (!raw)
679
+ return;
680
+ const config = parseJsonc(raw);
681
+ const permission = config && typeof config === "object" ? config.permission : undefined;
682
+ if (!permission || typeof permission !== "object" || Array.isArray(permission))
683
+ return;
684
+ const value = permission.external_directory;
685
+ if (typeof value === "string")
686
+ rule = value;
687
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
688
+ rule = rule && typeof rule === "object" ? { ...rule, ...value } : value;
689
+ }
291
690
  };
292
- entry.result = runModels(cli, env, false)
293
- .then((stdout) => stdout
294
- .split(/\r?\n/u)
295
- .some((line) => validModelSlug(line.trim())))
296
- .catch(() => false)
297
- .finally(() => {
298
- entry.expiresAt = Date.now() + MODEL_PROBE_TTL_MS;
299
- });
300
- modelProbeCache.set(cli, entry);
301
- return entry.result;
691
+ const global = opencodeConfigDir(env);
692
+ for (const name of ["config.json", "opencode.json", "opencode.jsonc"])
693
+ apply(readText(join(global, name)));
694
+ if (env.OPENCODE_CONFIG)
695
+ apply(readText(env.OPENCODE_CONFIG));
696
+ const projectFolders = [];
697
+ if (projectConfig) {
698
+ // Up to the git root, or the top of the disk outside git.
699
+ for (let folder = resolvePath(cwd);; folder = dirname(folder)) {
700
+ projectFolders.push(folder);
701
+ if (existsSync(join(folder, ".git")) || dirname(folder) === folder)
702
+ break;
703
+ }
704
+ for (const folder of [...projectFolders].reverse()) {
705
+ for (const name of ["opencode.json", "opencode.jsonc"])
706
+ apply(readText(join(folder, name)));
707
+ }
708
+ }
709
+ const home = env.HOME || env.USERPROFILE || homedir();
710
+ const configFolders = [
711
+ ...projectFolders.map((folder) => join(folder, ".opencode")),
712
+ join(home, ".opencode"),
713
+ ...(env.OPENCODE_CONFIG_DIR ? [env.OPENCODE_CONFIG_DIR] : []),
714
+ ];
715
+ for (const folder of configFolders) {
716
+ for (const name of ["opencode.json", "opencode.jsonc"])
717
+ apply(readText(join(folder, name)));
718
+ }
719
+ apply(env.OPENCODE_CONFIG_CONTENT);
720
+ return typeof rule === "string" ? rule : undefined;
721
+ }
722
+ /** OpenCode asks before a tool touches a folder outside the session's
723
+ * working folder (its `external_directory` permission), which stalled every
724
+ * turn that read an attachment on an approval card. Allow exactly the
725
+ * folders OpenMaus owns for this bot; every other folder keeps the person's
726
+ * rule, or OpenCode's default "ask". The rules depend only on the bot and the
727
+ * working folder, so a conversation keeps one process. */
728
+ function allowOwnedDirectories(env, botId, cwd) {
729
+ let permission = {};
730
+ if (env.OPENCODE_PERMISSION) {
731
+ try {
732
+ const parsed = JSON.parse(env.OPENCODE_PERMISSION);
733
+ // A policy the person set that is not an object is theirs to keep.
734
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
735
+ return;
736
+ permission = parsed;
737
+ }
738
+ catch {
739
+ return;
740
+ }
741
+ }
742
+ const directories = openCodeOwnedDirectories(botId, cwd);
743
+ if (!directories.length)
744
+ return;
745
+ const existing = permission.external_directory;
746
+ // OpenCode evaluates rules in order and the last match wins, so these go
747
+ // after anything already there: the person's single action first as "*",
748
+ // then OpenMaus's folders.
749
+ let rules;
750
+ if (typeof existing === "string")
751
+ rules = { "*": existing };
752
+ else if (existing && typeof existing === "object" && !Array.isArray(existing))
753
+ rules = { ...existing };
754
+ else {
755
+ const configured = configuredOpenCodeFolderAction(env, cwd, env.OPENCODE_DISABLE_PROJECT_CONFIG !== "1");
756
+ rules = configured ? { "*": configured } : {};
757
+ }
758
+ for (const directory of directories) {
759
+ rules[directory] = "allow";
760
+ rules[join(directory, "*")] = "allow";
761
+ }
762
+ permission.external_directory = rules;
763
+ env.OPENCODE_PERMISSION = JSON.stringify(permission);
302
764
  }
303
- /** Migrate the model name published during Ox Alpha's first preview. The
304
- * current CLI calls the same model `x-preview-f-free`; prefer Go for an old
305
- * Go bot with an explicit key, otherwise use Zen's anonymous/free route. */
306
- export function normalizeLegacyOpenCodeModel(model, env) {
307
- if (model !== "opencode-go/ox-alpha-free")
308
- return model;
309
- return env.OPENCODE_API_KEY
310
- ? "opencode-go/x-preview-f-free"
311
- : "opencode/x-preview-f-free";
765
+ /** Account failures in plain words, with the fix, naming the provider that
766
+ * refused: with provider keys and `opencode auth login` a model may be
767
+ * OpenRouter's or Anthropic's, not Zen's. Each stays under the 160
768
+ * characters a chat error row shows. */
769
+ export function describeOpenCodeAccountError(code, model) {
770
+ const provider = model && model.includes("/") ? model.slice(0, model.indexOf("/")) : "";
771
+ const zen = provider === "opencode";
772
+ const go = provider === "opencode-go";
773
+ const label = providerLabel(provider);
774
+ const name = zen ? "OpenCode Zen" : go ? "OpenCode Go" : provider ? (label.length > 24 ? provider.slice(0, 24) : label) : "OpenCode";
775
+ switch (code) {
776
+ case "invalid_credentials":
777
+ return zen || go || !provider
778
+ ? "OpenCode rejected its key, or has none for this model. Fix it in Settings → API keys or with `opencode auth login`."
779
+ : `OpenCode's ${name} key for this model is missing or was rejected. Fix it with \`opencode auth login\`, or choose another model.`;
780
+ case "insufficient_funds":
781
+ return zen || !provider
782
+ ? "Your OpenCode Zen balance has run out. Add credit at opencode.ai, or choose one of Zen's free models for this bot."
783
+ : `Your ${name} account has run out of credit. Add credit there, or choose another model for this bot.`;
784
+ case "inactive_subscription":
785
+ return go || !provider
786
+ ? "This model needs an active OpenCode Go subscription. Subscribe at opencode.ai, or choose a Zen model for this bot."
787
+ : `This model needs an active ${name} subscription. Renew it there, or choose another model for this bot.`;
788
+ case "quota_or_region_restriction":
789
+ return `${name} says this account's usage limit has been reached. Wait for it to reset, or choose another model for this bot.`;
790
+ }
791
+ }
792
+ /** OpenCode's logins, as one value that changes when `opencode auth login`
793
+ * adds or replaces one. A running `opencode acp` never reads auth.json again,
794
+ * so a conversation's process must be replaced once it changes. */
795
+ function openCodeLoginsFingerprint(env) {
796
+ return storedAuthPaths(env).map((path) => {
797
+ try {
798
+ const stat = statSync(path);
799
+ return `${stat.size}:${stat.mtimeMs}`;
800
+ }
801
+ catch {
802
+ return "-";
803
+ }
804
+ }).join("|");
312
805
  }
313
806
  const support = (loadCatalog) => ({
314
807
  driverKind: "opencodeGo",
315
808
  // Keep the historical driver kind so existing bots and instance config do
316
809
  // not break; only the product name/catalog expand from Go to OpenCode.
317
810
  displayName: "OpenCode",
318
- models: STATIC_MODELS,
811
+ models: NO_MODELS,
319
812
  defaultCli: "opencode",
320
813
  nativeSource: "opencode.acp",
321
814
  loginNote: "OpenCode has no usable models — run `opencode auth login` or connect a provider in the OpenCode app",
@@ -330,16 +823,29 @@ const support = (loadCatalog) => ({
330
823
  needsNode: true,
331
824
  },
332
825
  spawnArgs: () => ["acp"],
333
- credentialEnv: ["OPENCODE_API_KEY"],
826
+ credentialEnv: ["OPENCODE_API_KEY", ...OPENCODE_PROVIDER_ENV],
334
827
  selectModel: { configId: "model" },
828
+ // A retired id (x-preview-f-free, the Ox Alpha preview) or none at all:
829
+ // the catalog default when this session offers it, else the session's own.
830
+ fallbackModel: (sessionConfig, catalogDefault) => {
831
+ const offered = offeredModels(sessionConfig, "model");
832
+ if (catalogDefault && offered.includes(catalogDefault))
833
+ return catalogDefault;
834
+ return preferredOpenCodeModel(offered, catalogFromOpenCodeSession(sessionConfig)?.current) || null;
835
+ },
335
836
  modelVariants: true,
336
- resolveTurnModel: (model, env) => model
337
- ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model, env), env)
338
- : model,
339
- transformEnv: stripForeignProviderKeys,
340
- applyTurnEnv: (env, { fullAuto }) => {
341
- if (!fullAuto)
837
+ resolveTurnModel: (model, env) => model ? ensureOpenCodeInjectModel(model, env) : model,
838
+ transformEnv: withholdProviderKeysWhenManaged,
839
+ spawnFingerprint: openCodeLoginsFingerprint,
840
+ applyTurnEnv: (env, { fullAuto, botId, cwd }) => {
841
+ // In OpenMaus's own folders nothing is the person's project: a bot wrote
842
+ // it, and OpenCode would otherwise take it as configuration.
843
+ if (openMausOwnsWorkingFolder(cwd))
844
+ env.OPENCODE_DISABLE_PROJECT_CONFIG = "1";
845
+ if (!fullAuto) {
846
+ allowOwnedDirectories(env, botId, cwd);
342
847
  return;
848
+ }
343
849
  // Scope native permissions to this child, not the user's OpenCode config.
344
850
  // A wildcard alone leaves OpenCode's more-specific external-directory and
345
851
  // read rules in place. Replace the built-in rules as well, including path
@@ -352,14 +858,30 @@ const support = (loadCatalog) => ({
352
858
  },
353
859
  pickAuthMethod: () => null,
354
860
  authFailure: "continue",
861
+ // Setup status only. Turns are never gated on it: a missing or rejected
862
+ // key comes back from the prompt itself and is shown with its fix.
355
863
  isAuthenticated: async (env, config) => (Boolean(env.OPENCODE_API_KEY)
356
864
  || hasStoredOpenCodeAuth(env)
357
- || await canListOpenCodeModels(env, config.cli)),
358
- requireAuthenticationBeforeSpawn: true,
865
+ || await canRunOpenCode(env, config.cli, loadCatalog)),
359
866
  classifyError: classifyOpenCodeError,
867
+ describeAccountError: describeOpenCodeAccountError,
360
868
  resolveModels: async (environment, config) => mergeLocalInject(await loadCatalog(environment, config.cli), environment),
361
869
  buildPromptText: (turn) => turn.system ? `${turn.system}\n\n${turn.text}` : turn.text,
362
870
  });
871
+ /** OpenCode 1.18 reports a provider's account refusal as a JSON-RPC internal
872
+ * error wrapping an APIError; only the text says which. Recorded from the
873
+ * real CLI: `{code: -32603, message: "Internal error: Invalid API key.",
874
+ * data: {service: "session", errorName: "APIError"}}`. */
875
+ const ACCOUNT_ERROR_TEXT = [
876
+ [/insufficient (?:account )?(?:funds|balance|credits?)|out of credits?|no credits? (?:left|remaining)|payment required|\b402\b/iu, "insufficient_funds"],
877
+ // Not plain "rate limit exceeded": that is a transient 429, which OpenCode
878
+ // retries itself and automatic recovery may still route around.
879
+ [/\bquota\b|usage limit|(?:hourly|daily|weekly|monthly|5-hour) limit/iu, "quota_or_region_restriction"],
880
+ [/\bsubscription\b/iu, "inactive_subscription"],
881
+ // "User not found." is OpenRouter's refusal of an unknown key (recorded
882
+ // through opencode 1.18.27 after `opencode auth login` with a dummy key).
883
+ [/invalid api key|api key (?:is )?(?:invalid|missing|revoked|expired)|incorrect api key|unauthori[sz]ed|\b401\b|authentication (?:failed|required)|(?:^|internal error: )user not found\b/iu, "invalid_credentials"],
884
+ ];
363
885
  export function classifyOpenCodeError(error) {
364
886
  const value = error && typeof error === "object" ? error : {};
365
887
  const code = value.code;
@@ -375,6 +897,13 @@ export function classifyOpenCodeError(error) {
375
897
  return "upstream_outage";
376
898
  if (code === "MODEL_CATALOG_UNAVAILABLE")
377
899
  return "model_catalog_outage";
900
+ const data = value.data && typeof value.data === "object" && !Array.isArray(value.data)
901
+ ? value.data
902
+ : {};
903
+ if (code === -32603 && data.errorName === "APIError") {
904
+ const text = [value.message, data.message, data.details].filter((part) => typeof part === "string").join(" ");
905
+ return ACCOUNT_ERROR_TEXT.find(([pattern]) => pattern.test(text))?.[1];
906
+ }
378
907
  return undefined;
379
908
  }
380
909
  export const classifyOpenCodeGoError = classifyOpenCodeError;