openmausbot 0.1.92 → 0.1.94

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 (163) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +45 -1
  63. package/dist-server/container-mcp.js +99 -77
  64. package/dist-server/drivers/agents-proxy.js +40 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +18361 -13794
  68. package/dist-server/local-computer-proxy.js +5 -1
  69. package/dist-server/local-computer.js +1 -0
  70. package/dist-server/mcp-gate.js +152 -20
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +8872 -7620
  73. package/dist-server/pair-cli.js +8872 -7620
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +1 -0
  76. package/dist-server/server/auto-approve.js +33 -11
  77. package/dist-server/server/boat.js +3 -3
  78. package/dist-server/server/browser-live.js +14 -5
  79. package/dist-server/server/browser-runtime.js +6 -0
  80. package/dist-server/server/channel-queue.js +4 -1
  81. package/dist-server/server/cli.js +32 -12
  82. package/dist-server/server/cloud-move-http.js +9 -3
  83. package/dist-server/server/cloud-move.js +18 -0
  84. package/dist-server/server/cloud-owner.js +1 -1
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/computer-wait.js +33 -13
  87. package/dist-server/server/config.js +57 -10
  88. package/dist-server/server/container-computer.js +43 -32
  89. package/dist-server/server/decision-log.js +9 -6
  90. package/dist-server/server/delegations.js +47 -22
  91. package/dist-server/server/digest.js +12 -2
  92. package/dist-server/server/drivers/acp/core.js +229 -20
  93. package/dist-server/server/drivers/acp/cursor.js +13 -1
  94. package/dist-server/server/drivers/acp/grok.js +208 -3
  95. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  96. package/dist-server/server/drivers/acp/qwen.js +18 -0
  97. package/dist-server/server/drivers/agents-call.js +28 -0
  98. package/dist-server/server/drivers/agents-catalog.js +10 -1
  99. package/dist-server/server/drivers/boatagent.js +2 -0
  100. package/dist-server/server/drivers/builtIn.js +2 -0
  101. package/dist-server/server/drivers/cerebras.js +108 -0
  102. package/dist-server/server/drivers/chat-mcp-tools.js +45 -15
  103. package/dist-server/server/drivers/claude.js +35 -9
  104. package/dist-server/server/drivers/codex.js +84 -10
  105. package/dist-server/server/drivers/openai-chat.js +37 -5
  106. package/dist-server/server/drivers/pi.js +48 -4
  107. package/dist-server/server/drivers/prompt-split.js +2 -0
  108. package/dist-server/server/env-path.js +6 -2
  109. package/dist-server/server/environment.js +3 -0
  110. package/dist-server/server/guarded-requests.js +2 -1
  111. package/dist-server/server/index.js +1353 -311
  112. package/dist-server/server/live-call-controller.js +868 -0
  113. package/dist-server/server/live-call.js +216 -0
  114. package/dist-server/server/local-vm-stop-reason.js +26 -0
  115. package/dist-server/server/mcp-gate-config.js +43 -12
  116. package/dist-server/server/mcp-gate.js +127 -25
  117. package/dist-server/server/mcp-http.js +46 -8
  118. package/dist-server/server/mcp-remote-proxy.js +108 -0
  119. package/dist-server/server/new-bot-defaults.js +3 -0
  120. package/dist-server/server/peer-provenance.js +4 -2
  121. package/dist-server/server/permission-proxy.js +15 -1
  122. package/dist-server/server/plan-usage.js +797 -0
  123. package/dist-server/server/provider-key-check.js +2 -1
  124. package/dist-server/server/proxy-paths.js +1 -0
  125. package/dist-server/server/redact.js +2 -0
  126. package/dist-server/server/request-auth.js +3 -2
  127. package/dist-server/server/routes/desktop-viewer.js +50 -6
  128. package/dist-server/server/routes/live.js +98 -0
  129. package/dist-server/server/routines.js +6 -3
  130. package/dist-server/server/skill-library.js +207 -0
  131. package/dist-server/server/skills-library-migration.js +235 -0
  132. package/dist-server/server/skills.js +66 -3
  133. package/dist-server/server/steer-queue.js +13 -1
  134. package/dist-server/server/store.js +86 -4
  135. package/dist-server/server/surface.js +9 -3
  136. package/dist-server/server/team-backup.js +2 -1
  137. package/dist-server/server/thread-events.js +1 -1
  138. package/dist-server/server/tunnel.js +20 -1
  139. package/dist-server/server/turn-log.js +10 -0
  140. package/dist-server/server/turn-resources.js +124 -0
  141. package/dist-server/server/vps-computer.js +1 -1
  142. package/dist-server/server/workspace-backup.js +22 -0
  143. package/dist-server/server/workspace.js +1 -1
  144. package/dist-server/shared/approval-mode.js +1 -1
  145. package/dist-server/shared/ask-question.js +13 -0
  146. package/dist-server/shared/call-consent.js +28 -0
  147. package/dist-server/shared/live-approval.js +124 -0
  148. package/dist-server/shared/live-call.js +176 -0
  149. package/dist-server/shared/redact.js +1 -0
  150. package/dist-server/shared/skill-md.js +10 -0
  151. package/dist-server/shared/team-backup.js +3 -0
  152. package/dist-server/shared/tool-name.js +22 -0
  153. package/dist-server/shared/tool-scope-support.js +25 -0
  154. package/dist-server/shared/tool-scope.js +136 -0
  155. package/dist-server/src/lib/thread-refs.js +406 -0
  156. package/dist-server/vps-container-mcp.js +99 -77
  157. package/dist-server/workspace-backup.worker.js +103 -100
  158. package/enterprise/server/index.js +1 -0
  159. package/package.json +1 -1
  160. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  161. package/dist/assets/index-Cdmjrl28.css +0 -1
  162. package/dist/assets/index-DR7YPMkW.js +0 -597
  163. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -94,7 +94,70 @@ function questionAnswersById(message, questions) {
94
94
  return only && flat ? { [only.id]: flat } : {};
95
95
  }
96
96
 
97
+ // shared/tool-scope.ts
98
+ var SERVER_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
99
+ var CONTROL_CHARACTER = /[\x00-\x1f\x7f]/;
100
+ var MAX_SELECTORS = 256;
101
+ var MAX_SELECTOR_LENGTH = 1024;
102
+ function validName(name) {
103
+ return typeof name === "string" && name.trim().length > 0 && name.length <= MAX_SELECTOR_LENGTH && !CONTROL_CHARACTER.test(name) && !name.includes("*");
104
+ }
105
+ function selectorIdentity(selector) {
106
+ if (selector.length > MAX_SELECTOR_LENGTH || CONTROL_CHARACTER.test(selector)) return;
107
+ if (selector.startsWith("native:")) {
108
+ const name = selector.slice(7);
109
+ if (name === "*" || validName(name)) return { kind: "native", name };
110
+ } else if (selector.startsWith("mcp:")) {
111
+ const separator = selector.indexOf(":", 4);
112
+ if (separator < 0) return;
113
+ const server = selector.slice(4, separator);
114
+ const name = selector.slice(separator + 1);
115
+ if (SERVER_NAME.test(server) && (name === "*" || validName(name))) {
116
+ return { kind: "mcp", server, name };
117
+ }
118
+ }
119
+ }
120
+ function parseToolScope(value) {
121
+ if (value === void 0) return { ok: true, scope: void 0 };
122
+ const invalid = { ok: false, error: "Tool selection must contain only valid allow and deny lists." };
123
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid;
124
+ const prototype = Object.getPrototypeOf(value);
125
+ if (prototype !== Object.prototype && prototype !== null) return invalid;
126
+ const scope = {};
127
+ for (const key of Reflect.ownKeys(value)) {
128
+ if (key !== "allow" && key !== "deny") return invalid;
129
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
130
+ if (!descriptor || !("value" in descriptor)) return invalid;
131
+ const list = descriptor.value;
132
+ if (!Array.isArray(list) || list.length > MAX_SELECTORS) return invalid;
133
+ if (!Array.from(list).every((entry) => typeof entry === "string" && selectorIdentity(entry))) return invalid;
134
+ scope[key] = [...new Set(list)];
135
+ }
136
+ return { ok: true, scope };
137
+ }
138
+ function matches(selector, tool) {
139
+ const selected = selectorIdentity(selector);
140
+ return selected?.kind === tool.kind && (selected.kind !== "mcp" || tool.kind === "mcp" && selected.server === tool.server) && (selected.name === "*" || selected.name === tool.name);
141
+ }
142
+ function permits(scope, tool) {
143
+ if (!validName(tool.name) || tool.kind === "mcp" && !SERVER_NAME.test(tool.server)) return false;
144
+ return (scope?.allow === void 0 || scope.allow.some((selector) => matches(selector, tool))) && !scope?.deny?.some((selector) => matches(selector, tool));
145
+ }
146
+ function allowsTool(scope, tool) {
147
+ const parsed = parseToolScope(scope);
148
+ return parsed.ok && permits(parsed.scope, tool);
149
+ }
150
+
97
151
  // server/permission-proxy.ts
152
+ var selection = parseToolScope(process.env.OMB_PERMISSION_TOOL_SCOPE === void 0 ? void 0 : (() => {
153
+ try {
154
+ return JSON.parse(process.env.OMB_PERMISSION_TOOL_SCOPE);
155
+ } catch {
156
+ return null;
157
+ }
158
+ })());
159
+ if (!selection.ok) throw new Error(selection.error);
160
+ var allowQuestion = allowsTool(selection.scope, { kind: "mcp", server: "ogb", name: "ask_user" });
98
161
  var socketPath = process.argv[2] ?? "";
99
162
  var waiting = /* @__PURE__ */ new Map();
100
163
  var conn = connect(socketPath);
@@ -203,11 +266,12 @@ async function handle(msg) {
203
266
  }
204
267
  });
205
268
  }
206
- if (msg.method === "tools/list") return send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } });
269
+ if (msg.method === "tools/list") return send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS.filter((tool) => tool.name === "approve" || allowQuestion) } });
207
270
  if (msg.method === "tools/call") {
208
271
  const name = msg.params?.name;
209
272
  const args = msg.params?.arguments ?? {};
210
273
  const reply = (text3) => send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: text3 }] } });
274
+ if (name === "ask_user" && !allowQuestion) return reply("OpenMausBot: this question tool is excluded by the bot's tool selection.");
211
275
  if (name === "approve" && args.tool_name === ASK_USER_QUESTION) {
212
276
  return reply(await answerNativeQuestions(args.input));
213
277
  }
@@ -17,6 +17,7 @@ var SPAWNED_PROXIES = {
17
17
  dweb: resolveProxy("drivers/dweb-proxy"),
18
18
  connectors: resolveProxy("connector-proxy"),
19
19
  mcpGate: resolveProxy("mcp-gate"),
20
+ mcpRemote: resolveProxy("mcp-remote-proxy"),
20
21
  phone: resolveProxy("drivers/phone-proxy"),
21
22
  hook: resolveProxy("hooks/omb-hook"),
22
23
  // Loaded by the external `pi` process via `-e`, not by this server — but
@@ -25,7 +25,7 @@ export async function deliverFullAccessApproval(adapter, threadId, requestId, tu
25
25
  }
26
26
  /** Full access is the person's explicit grant to this receiving bot, including
27
27
  * delegated work. It never inherits the sender's mode or elevates another bot
28
- * — with the one exception below (delegationInheritsFullAccess), applied
28
+ * — with the one exception below (delegatedApprovalMode), applied
29
29
  * where a Chief's delegated thread is created rather than here.
30
30
  * Custom is a provider-config choice rather than an app Full-access grant, so
31
31
  * peer-started Custom turns use Auto. Provider support and grant confirmation
@@ -35,16 +35,38 @@ export function approvalModeForOrigin(mode, origin) {
35
35
  return "auto";
36
36
  return mode;
37
37
  }
38
- /** Whether work a bot hands to a teammate runs with Full access. Only a Chief
39
- * of Staff passes access on, and only the Full access the person gave it for
40
- * the conversation it is delegating from: the Chief exists to get the team's
41
- * work done without the person answering every card, and a teammate stopping
42
- * that work to ask defeats the grant. The recipient's engine has to implement
43
- * Full, or the work keeps the recipient's own level. A bot never elevates
44
- * itself this way. */
45
- export function delegationInheritsFullAccess(input) {
46
- return input.senderIsChief && input.senderHasFullAccess && !input.sameBot
47
- && supportsApprovalMode(input.recipientDriverKind, "full");
38
+ /** Levels by how much runs without asking. Custom is a provider config of
39
+ * its own and is never compared. */
40
+ const LEVEL_RANK = { ask: 0, edits: 1, auto: 2, full: 3 };
41
+ /** The level work a bot hands to a teammate starts at, or null to keep the
42
+ * teammate's own. A Chief of Staff's level flows down: the person set it on
43
+ * the Chief so the team's work runs that way, and switching every thread the
44
+ * Chief opens with a teammate back off "Ask for approval" was the friction
45
+ * people reported. Only a Chief passes its level on, only the level of the
46
+ * conversation it delegates from, and only upward: a teammate already on a
47
+ * higher level keeps it, and one on Custom keeps its own config. The level
48
+ * is capped at what the teammate's engine implements (Full or Auto-accept
49
+ * edits are not everywhere), stepping down to the next level it has. A bot
50
+ * never raises itself this way. */
51
+ export function delegatedApprovalMode(input) {
52
+ if (!input.senderIsChief || input.sameBot)
53
+ return null;
54
+ // A Chief's Custom config is its own; for a teammate it reads as Approve
55
+ // for me, as for any peer-started Custom turn (approvalModeForOrigin).
56
+ const sender = LEVEL_RANK[approvalModeForOrigin(input.senderMode, { peerInitiated: true })] ?? 0;
57
+ const own = LEVEL_RANK[input.recipientMode];
58
+ if (own === undefined)
59
+ return null;
60
+ for (const mode of ["full", "auto", "edits"]) {
61
+ const rank = LEVEL_RANK[mode];
62
+ if (rank > sender)
63
+ continue;
64
+ if (rank <= own)
65
+ return null;
66
+ if (supportsApprovalMode(input.recipientDriverKind, mode))
67
+ return mode;
68
+ }
69
+ return null;
48
70
  }
49
71
  // Tools that ask a PERSON something. A question exists so that a human
50
72
  // decides; any mode answering one on their behalf defeats the only reason
@@ -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
@@ -10,6 +10,9 @@ const HEARTBEAT_MS = 10_000;
10
10
  // The native press resolver supplies the virtual key codes and Enter/Tab text
11
11
  // that its raw input_keyboard relay omits. Keep unknown keys literal.
12
12
  const DISCRETE_KEYS = new Set(["Backspace", "Enter", "Tab", "Escape", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
13
+ // The panel has no take-control button: interacting takes control, and an
14
+ // interrupted action is recovered from the panel's menu.
15
+ const RESTART_NEEDED = "A browser action was interrupted. Choose Restart browser… in the browser menu before continuing.";
13
16
  export class BrowserLiveError extends Error {
14
17
  status;
15
18
  constructor(message, status = 400) { super(message); this.name = "BrowserLiveError"; this.status = status; }
@@ -569,22 +572,24 @@ export class BrowserLive {
569
572
  try {
570
573
  const taking = this.runtime.take(viewer.session, viewer.id);
571
574
  this.control(viewer.session);
572
- await taking;
575
+ // waited: the bot's own action finished first, so input the person
576
+ // aimed at the page before the grant may no longer fit it.
577
+ const waited = await taking;
573
578
  if (!this.current(viewer)) {
574
579
  this.close(viewer);
575
580
  throw new BrowserLiveError("This browser view closed.", 409);
576
581
  }
577
- return { ok: true };
582
+ return { ok: true, waited: waited === true };
578
583
  }
579
584
  catch {
580
- throw new BrowserLiveError("Another browser view or bot action is using this browser. Try again shortly.", 409);
585
+ throw this.refusal(viewer, "Another browser view or bot action is using this browser. Try again shortly.");
581
586
  }
582
587
  finally {
583
588
  this.control(viewer.session);
584
589
  }
585
590
  }
586
591
  if (!this.runtime.canControl(viewer.session, viewer.id))
587
- throw new BrowserLiveError("Take control of this browser before interacting.", 409);
592
+ throw this.refusal(viewer, "Browser control changed. Try again.");
588
593
  if (viewer.pendingActions >= 32)
589
594
  throw new BrowserLiveError("Too many browser actions are pending. Try again shortly.", 429);
590
595
  viewer.pendingActions += 1;
@@ -600,13 +605,17 @@ export class BrowserLive {
600
605
  return { ok: true };
601
606
  }
602
607
  catch (error) {
603
- throw error instanceof BrowserLiveError ? error : new BrowserLiveError("Browser control changed. Take control again to continue.", 409);
608
+ throw error instanceof BrowserLiveError ? error : this.refusal(viewer, "Browser control changed. Try again.");
604
609
  }
605
610
  finally {
606
611
  viewer.pendingActions -= 1;
607
612
  this.control(viewer.session);
608
613
  }
609
614
  }
615
+ /** Why this viewer cannot act now: an interrupted browser needs a restart. */
616
+ refusal(viewer, otherwise) {
617
+ return new BrowserLiveError(this.runtime.interrupted(viewer.session) ? RESTART_NEEDED : otherwise, 409);
618
+ }
610
619
  closeForSession(session) { for (const viewer of this.viewers.values())
611
620
  if (viewer.session === session)
612
621
  this.close(viewer); }
@@ -364,6 +364,8 @@ export class BrowserRuntime {
364
364
  };
365
365
  return method === "tools/call" ? this.withAgentAction(session, invoke) : invoke();
366
366
  }
367
+ /** Resolves true when the grant had to wait for the bot's own browser
368
+ * action to finish: the page may have changed since the person aimed. */
367
369
  async take(session, owner) {
368
370
  if (!owner)
369
371
  throw new Error("Browser control requires an owner.");
@@ -374,6 +376,7 @@ export class BrowserRuntime {
374
376
  throw new Error("Another person controls this browser.");
375
377
  gate.owner = owner; // synchronous: no new agent work slips in while draining.
376
378
  gate.ready = false;
379
+ const waited = gate.agents > 0;
377
380
  await new Promise((resolve, reject) => {
378
381
  const finish = (error) => {
379
382
  clearTimeout(timer);
@@ -398,12 +401,15 @@ export class BrowserRuntime {
398
401
  gate.changed.add(check);
399
402
  check();
400
403
  });
404
+ return waited;
401
405
  }
402
406
  canControl(session, owner) {
403
407
  const gate = this.gates.get(session);
404
408
  return Boolean(owner && gate?.owner === owner && gate.ready && !gate.releasing && !gate.closing && !gate.uncertain && gate.agents === 0);
405
409
  }
406
410
  heldBy(session) { return this.gates.get(session)?.owner ?? null; }
411
+ /** An interrupted action left the browser's state unknown; only a restart clears it. */
412
+ interrupted(session) { return this.gates.get(session)?.uncertain === true; }
407
413
  /** A disconnected viewer may leave a physical key/button pressed. Never
408
414
  * let an agent inherit that input state; explicit restart clears it. */
409
415
  abandonHumanInput(session, owner) {
@@ -17,8 +17,11 @@ export function restoreChannelMessages() {
17
17
  const entry = queues.get(row.threadId) ?? { groupId: row.ownerId, items: [] };
18
18
  if (entry.groupId !== row.ownerId)
19
19
  throw new Error("queued task belongs to another channel");
20
+ // a channel line is only ever stamped "api"; "call" belongs to 1:1 rows
21
+ const { via, ...payload } = row.payload;
20
22
  entry.items.push({
21
- ...row.payload,
23
+ ...payload,
24
+ ...(via === "api" ? { via } : {}),
22
25
  id: row.id,
23
26
  mode: row.payload.mode ?? "chat",
24
27
  // rows queued before timestamps were kept read as queued at restore
@@ -812,7 +812,7 @@ export async function runBrowser(options, io = defaultIo()) {
812
812
  io.error("on Linux, install Chrome's system libraries with `sudo openmausbot browser install --with-deps`, then retry `openmausbot browser install` as the user running serve");
813
813
  return 1;
814
814
  }
815
- io.log("browser installed for this user and data directory; run serve as the same user, then enable it under Settings → Experimental and per bot");
815
+ io.log("browser installed for this user and data directory; run serve as the same user, then enable it under Settings → Computers and per bot");
816
816
  if (process.platform === "linux" && options.withDeps)
817
817
  io.log("if serve runs as another user, run `openmausbot browser install` from that user's login shell too");
818
818
  return 0;
@@ -834,8 +834,13 @@ export function serverEntry(here = HERE) {
834
834
  }
835
835
  /** Everything `--tunnel` needs before the server starts, or the one reason
836
836
  * it cannot have it. Fails closed: no silent fallback to a local-only server. */
837
- async function planTunnel(options, log) {
837
+ async function planTunnel(options, log, recovery) {
838
838
  let access = null;
839
+ let account = null;
840
+ const fail = (error) => {
841
+ account?.service.dispose();
842
+ return { error };
843
+ };
839
844
  const credential = fleetCredential();
840
845
  if (credential) {
841
846
  // A fleet-started container: the credential is the whole identity.
@@ -848,36 +853,41 @@ async function planTunnel(options, log) {
848
853
  }
849
854
  }
850
855
  else {
851
- const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
856
+ account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion(), recovery });
852
857
  if (account.credentials.status === "unavailable")
853
- return { error: `${account.credentials.file} exists but could not be read; fix or remove it` };
858
+ return fail(`${account.credentials.file} exists but could not be read; fix or remove it`);
854
859
  if (!describeTunnelAccount(account.credentials.read()).email) {
855
- return { error: "no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`" };
860
+ return fail("no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`");
856
861
  }
857
862
  // A fresh connector token when the control plane answers; the saved one otherwise.
858
863
  try {
859
864
  const state = await account.service.retry();
860
- if (state.message && !tunnelAccess(account.credentials.read()))
861
- log(`tunnel: ${state.message}`);
865
+ const saved = tunnelAccess(account.credentials.read()) !== null;
866
+ // Said even when a saved address exists: an expired sign-in means this
867
+ // machine can no longer repair that address on its own.
868
+ if (state.status === "signed-out")
869
+ log(`tunnel: the sign-in on this machine expired; run \`openmausbot login\` to renew it${saved ? " (serving the saved address until then)" : ""}`);
870
+ else if (state.message)
871
+ log(`tunnel: ${state.message}${saved ? " Serving the saved address for now." : ""}`);
862
872
  }
863
873
  catch (error) {
864
874
  log(`tunnel: control plane not reachable right now (${message(error)}); using the saved address`);
865
875
  }
866
876
  access = tunnelAccess(account.credentials.read());
867
877
  if (!access)
868
- return { error: "this machine has no public address; run `openmausbot login` again" };
878
+ return fail("this machine has no public address; run `openmausbot login` again");
869
879
  }
870
880
  let binary;
871
881
  try {
872
882
  binary = await ensureCloudflared({ dataDir: options.dataDir, log });
873
883
  }
874
884
  catch (error) {
875
- return { error: `--tunnel: ${message(error)}` };
885
+ return fail(`--tunnel: ${message(error)}`);
876
886
  }
877
887
  const guardian = guardianEntry();
878
888
  if (!guardian)
879
- return { error: "--tunnel: the connector guardian is missing from this install" };
880
- return { access, binary, guardian, origin: createTunnelOrigin() };
889
+ return fail("--tunnel: the connector guardian is missing from this install");
890
+ return { access, binary, guardian, origin: createTunnelOrigin(), account };
881
891
  }
882
892
  export async function runServe(options, log = console.log) {
883
893
  const { browserEngineStatus, describeBrowserEngine } = await import("./browser-engine.js");
@@ -896,8 +906,11 @@ export async function runServe(options, log = console.log) {
896
906
  tailscale = probe.status;
897
907
  }
898
908
  let plan = null;
909
+ // The tunnel this process serves, once it runs; the account service's
910
+ // background recovery reads it (see TunnelRecovery).
911
+ const serving = { tunnel: null };
899
912
  if (options.tunnel) {
900
- const planned = await planTunnel(options, log);
913
+ const planned = await planTunnel(options, log, { running: () => serving.tunnel });
901
914
  if ("error" in planned) {
902
915
  console.error(planned.error);
903
916
  return 1;
@@ -991,6 +1004,7 @@ export async function runServe(options, log = console.log) {
991
1004
  catch (error) {
992
1005
  if ((tailscaleServing || (startupCancelled && tailscaleAttempted)) && tailscale)
993
1006
  await tailscaleServeOff(tailscale).catch(() => undefined);
1007
+ plan?.account?.service.dispose();
994
1008
  if (plan)
995
1009
  cleanupTunnelOrigin(plan.origin);
996
1010
  if (error instanceof SetupCancelled) {
@@ -1015,6 +1029,8 @@ export async function runServe(options, log = console.log) {
1015
1029
  let stopping = null;
1016
1030
  const stop = () => {
1017
1031
  stopping ??= (async () => {
1032
+ serving.tunnel = null;
1033
+ plan?.account?.service.dispose();
1018
1034
  // The gateway and the edge stop accepting before the server they forward to goes away.
1019
1035
  if (tunnel)
1020
1036
  await tunnel.stop().catch(() => undefined);
@@ -1083,6 +1099,10 @@ export async function runServe(options, log = console.log) {
1083
1099
  onState: (state) => log(describeTunnelState(state, plan.access.endpoint)),
1084
1100
  });
1085
1101
  tunnel.started.catch((error) => log(`tunnel: ${message(error)}`));
1102
+ // From here on the endpoint is checked every 15 minutes; one reclaimed
1103
+ // while this machine was offline is re-created behind the same address.
1104
+ serving.tunnel = tunnel;
1105
+ void plan.account?.service.restore().catch(() => undefined);
1086
1106
  }
1087
1107
  log("");
1088
1108
  log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
@@ -1,11 +1,17 @@
1
1
  import { z } from "zod";
2
- import { backupsBytes, beginUpload, CLOUD_MOVE_MAX_BYTES, CLOUD_MOVE_MAX_PART_BYTES, CLOUD_MOVE_PART_BYTES, completedUpload, discardNextPreviousCloud, discardUpload, forgetMoveRestore, freeVolumeBytes, isEmptyWorkspace, moveSpaceNeeded, noteMoveRestore, prepareNextPreviousCloud, previousCloud, previousCloudArchiveBytes, removeMoveFiles, stagePreviousCloud, tidyCloudMoveStorage, uploadStatus, validUploadDeclaration, workspaceContents, workspaceMoveSize, writeUploadPart, } from "./cloud-move.js";
2
+ import { backupsBytes, beginUpload, CLOUD_MOVE_MAX_BYTES, CLOUD_MOVE_MAX_PART_BYTES, CLOUD_MOVE_PART_BYTES, completedUpload, discardNextPreviousCloud, discardUpload, enabledRoutineCount, forgetMoveRestore, freeVolumeBytes, isEmptyWorkspace, moveSpaceNeeded, noteMoveRestore, prepareNextPreviousCloud, previousCloud, previousCloudArchiveBytes, removeMoveFiles, stagePreviousCloud, tidyCloudMoveStorage, totalVolumeBytes, uploadStatus, validUploadDeclaration, workspaceContents, workspaceMoveSize, writeUploadPart, } from "./cloud-move.js";
3
3
  import { commitPendingWorkspaceRestore, createWorkspaceBackup, stageWorkspaceBackup } from "./workspace-backup.js";
4
4
  export const CLOUD_MOVE_PREFIX = "/api/cloud-move";
5
5
  const failure = (message, status) => Object.assign(new Error(message), { status });
6
6
  const HELD_CACHE_MS = 30_000;
7
7
  export function createCloudMoveRoutes(options) {
8
8
  const freeBytes = options.freeBytes ?? freeVolumeBytes;
9
+ const volumeBytes = (path) => { try {
10
+ return (options.volumeBytes ?? totalVolumeBytes)(path);
11
+ }
12
+ catch {
13
+ return null;
14
+ } };
9
15
  // A move's restore that startup has installed keeps no safety copy or
10
16
  // staged files; an abandoned upload goes after a day.
11
17
  if (options.cloudHome) {
@@ -113,7 +119,7 @@ export function createCloudMoveRoutes(options) {
113
119
  if (!auth.scopes.includes("admin"))
114
120
  throw failure("Only the owner can move a workspace.", 403);
115
121
  if (method === "GET" && path === `${CLOUD_MOVE_PREFIX}/estimate`) {
116
- json(res, 200, { ...workspaceContents(options.dataDir), ...workspaceMoveSize(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES });
122
+ json(res, 200, { ...workspaceContents(options.dataDir), ...workspaceMoveSize(options.dataDir), routines: enabledRoutineCount(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES });
117
123
  return true;
118
124
  }
119
125
  if (!options.cloudHome)
@@ -126,7 +132,7 @@ export function createCloudMoveRoutes(options) {
126
132
  if (!held || held.at + HELD_CACHE_MS < Date.now())
127
133
  held = { at: Date.now(), bytes: backupsBytes(options.dataDir) };
128
134
  json(res, 200, {
129
- contents, empty: isEmptyWorkspace(contents), freeBytes: freeBytes(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES,
135
+ contents, empty: isEmptyWorkspace(contents), freeBytes: freeBytes(options.dataDir), volumeBytes: volumeBytes(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES,
130
136
  partBytes: CLOUD_MOVE_PART_BYTES, upload: uploadStatus(options.dataDir), previous: previousCloud(options.dataDir), job,
131
137
  // What backups and the previous Cloud hold on the volume.
132
138
  heldBytes: held.bytes,
@@ -96,6 +96,24 @@ export function freeVolumeBytes(path) {
96
96
  const free = disk.bavail * disk.bsize;
97
97
  return free > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(free);
98
98
  }
99
+ /** The whole volume, so the app can tell how far a disk that grows may still grow. */
100
+ export function totalVolumeBytes(path) {
101
+ const disk = statfsSync(path, { bigint: true });
102
+ const total = disk.blocks * disk.bsize;
103
+ return total > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(total);
104
+ }
105
+ /** Routines switched on in this workspace. A move brings them paused, so the
106
+ * app says how many to turn on there. */
107
+ export function enabledRoutineCount(dataDir) {
108
+ try {
109
+ const value = JSON.parse(readFileSync(join(dataDir, "routines.json"), "utf8"));
110
+ const routines = value && typeof value === "object" ? value.routines : undefined;
111
+ return Array.isArray(routines) ? routines.filter((routine) => routine && typeof routine === "object" && routine.enabled !== false).length : 0;
112
+ }
113
+ catch {
114
+ return 0;
115
+ }
116
+ }
99
117
  /** Peak extra space a move of `upload` bytes needs: the upload, its decrypted
100
118
  * copy and its staged files exist together while it is checked; the restore
101
119
  * then installs from the staged copy. Replacing a workspace first backs it up
@@ -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) {
@@ -2,11 +2,11 @@
2
2
  //
3
3
  // The chip used to read "Waiting for computer — X / Y is using it; will
4
4
  // continue automatically", which people read as an error about a machine
5
- // they could not see. It is a queue position: this turn is behind one named
6
- // turn on one desktop, and it starts on its own when that turn ends. Say
7
- // that, name the holder as a bot running a thread, and keep the same words
8
- // on every surface (chat chip, room chip, the gate's refusal) so a person
9
- // who has read it once recognises it everywhere.
5
+ // they could not see. It is a queue position: this turn sits behind a named
6
+ // turn on one desktop — and every turn queued ahead of it — and starts on
7
+ // its own when they finish. Say that, name the holder as a bot running a
8
+ // thread, and keep the same words on every surface (chat chip, room chip,
9
+ // the gate's refusal) so a person who has read it once recognises it everywhere.
10
10
  /** Whole minutes, or seconds under one minute, singular at exactly one. */
11
11
  const minutes = (ms) => {
12
12
  const value = ms >= 60_000 ? Math.round(ms / 60_000) : Math.round(ms / 1000);
@@ -19,11 +19,30 @@ export function computerWaitDuration(ms) {
19
19
  return ms < 1_000 ? "under a second" : minutes(ms);
20
20
  }
21
21
  const holderPhrase = (holder) => holder.task ? `${holder.name} is running ${holder.task}` : `${holder.name} is using it`;
22
- /** The chip a turn shows while it is queued behind another turn's desktop. */
23
- export function computerWaitingText(holder) {
22
+ const ordinal = (position) => {
23
+ const mod100 = position % 100;
24
+ const mod10 = position % 10;
25
+ const suffix = mod100 >= 11 && mod100 <= 13 ? "th" : mod10 === 1 ? "st" : mod10 === 2 ? "nd" : mod10 === 3 ? "rd" : "th";
26
+ return `${position}${suffix}`;
27
+ };
28
+ /** The chip a turn shows while it is queued behind another turn's desktop:
29
+ * its stable queue position, the holder, and — only once waits here have
30
+ * history — about how long they have been taking. */
31
+ export function computerWaitingText(holder, queue) {
32
+ const position = queue?.position && queue.position > 0 ? queue.position : undefined;
33
+ const place = position ? ` — ${ordinal(position)} in queue` : "";
34
+ const estimate = queue?.estimateMs !== undefined ? `; recent waits here have taken ${computerWaitDuration(queue.estimateMs)}` : "";
35
+ // Behind more than the holder, that turn ending is not this turn's start:
36
+ // the released seat goes to the front waiter first, so this turn starts
37
+ // only once every turn ahead of it has finished (#1652).
38
+ const starts = position && position > 1
39
+ ? "Starts automatically when the turns ahead finish"
40
+ : holder
41
+ ? "Starts automatically when that finishes"
42
+ : "Starts automatically when it is free";
24
43
  if (!holder)
25
- return "Waiting for its turn on this computer. Starts automatically when it is free.";
26
- return `Waiting for its turn on this computer — ${holderPhrase(holder)}. Starts automatically when that finishes.`;
44
+ return `Waiting for its turn on this computer${place}. ${starts}${estimate}.`;
45
+ return `Waiting for its turn on this computer${place} — ${holderPhrase(holder)}. ${starts}${estimate}.`;
27
46
  }
28
47
  /** The resolution appended once the wait landed and this turn holds the
29
48
  * desktop: how long it waited, and who held it. The waiting chip stays as
@@ -38,9 +57,10 @@ export function computerStoppedWaitingText(holder, waitedMs) {
38
57
  const who = holder ? ` — ${holderPhrase(holder)}` : "";
39
58
  return `Stopped waiting for the computer after ${computerWaitDuration(waitedMs)}${who}.`;
40
59
  }
41
- /** The error after the wait ceiling: still names who holds it, and says
42
- * what a person can do — stop that turn, or move this one. */
43
- export function computerStillBusyText(holder, ceilingMs) {
60
+ /** The resolution appended when the wait ceiling parks the turn (#1651):
61
+ * still names who holds it, but the work is not lost — the turn settled and
62
+ * resumes on its own when the desktop frees, so nobody has to fix it. */
63
+ export function computerParkedText(holder, ceilingMs) {
44
64
  const who = holder ? ` — ${holderPhrase(holder).replace(" is running ", " is still running ").replace(" is using it", " is still using it")}` : "";
45
- return `Computer is still busy after ${minutes(ceilingMs)}${who}. Stop that turn, or run this on another computer.`;
65
+ return `Computer still busy after ${minutes(ceilingMs)}${who}. Parked — it continues automatically when the computer is free.`;
46
66
  }