openmausbot 0.1.93 → 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 (140) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.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-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.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 +21 -0
  63. package/dist-server/container-mcp.js +93 -76
  64. package/dist-server/drivers/agents-proxy.js +38 -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 +16942 -13191
  68. package/dist-server/local-computer.js +1 -0
  69. package/dist-server/mcp-gate.js +147 -19
  70. package/dist-server/mcp-remote-proxy.js +437 -0
  71. package/dist-server/openmausbot.js +8233 -7651
  72. package/dist-server/pair-cli.js +8233 -7651
  73. package/dist-server/permission-proxy.js +65 -1
  74. package/dist-server/proxy-paths.js +1 -0
  75. package/dist-server/server/auto-approve.js +33 -11
  76. package/dist-server/server/browser-live.js +14 -5
  77. package/dist-server/server/browser-runtime.js +6 -0
  78. package/dist-server/server/channel-queue.js +4 -1
  79. package/dist-server/server/cli.js +32 -12
  80. package/dist-server/server/cloud-move-http.js +9 -3
  81. package/dist-server/server/cloud-move.js +18 -0
  82. package/dist-server/server/computer-wait.js +33 -13
  83. package/dist-server/server/config.js +44 -10
  84. package/dist-server/server/decision-log.js +9 -6
  85. package/dist-server/server/delegations.js +47 -22
  86. package/dist-server/server/drivers/acp/core.js +57 -8
  87. package/dist-server/server/drivers/acp/grok.js +208 -3
  88. package/dist-server/server/drivers/agents-call.js +28 -0
  89. package/dist-server/server/drivers/agents-catalog.js +10 -1
  90. package/dist-server/server/drivers/boatagent.js +2 -0
  91. package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
  92. package/dist-server/server/drivers/claude.js +18 -6
  93. package/dist-server/server/drivers/codex.js +84 -10
  94. package/dist-server/server/drivers/openai-chat.js +11 -3
  95. package/dist-server/server/drivers/pi.js +48 -4
  96. package/dist-server/server/environment.js +3 -0
  97. package/dist-server/server/guarded-requests.js +2 -1
  98. package/dist-server/server/index.js +1143 -254
  99. package/dist-server/server/live-call-controller.js +868 -0
  100. package/dist-server/server/live-call.js +216 -0
  101. package/dist-server/server/mcp-gate-config.js +43 -12
  102. package/dist-server/server/mcp-gate.js +127 -25
  103. package/dist-server/server/mcp-http.js +46 -8
  104. package/dist-server/server/mcp-remote-proxy.js +108 -0
  105. package/dist-server/server/new-bot-defaults.js +3 -0
  106. package/dist-server/server/peer-provenance.js +4 -2
  107. package/dist-server/server/permission-proxy.js +15 -1
  108. package/dist-server/server/plan-usage.js +797 -0
  109. package/dist-server/server/proxy-paths.js +1 -0
  110. package/dist-server/server/redact.js +2 -0
  111. package/dist-server/server/request-auth.js +3 -2
  112. package/dist-server/server/routes/live.js +98 -0
  113. package/dist-server/server/skill-library.js +207 -0
  114. package/dist-server/server/skills-library-migration.js +235 -0
  115. package/dist-server/server/skills.js +66 -3
  116. package/dist-server/server/steer-queue.js +13 -1
  117. package/dist-server/server/store.js +32 -4
  118. package/dist-server/server/surface.js +9 -3
  119. package/dist-server/server/team-backup.js +2 -1
  120. package/dist-server/server/thread-events.js +1 -1
  121. package/dist-server/server/tunnel.js +20 -1
  122. package/dist-server/server/turn-log.js +10 -0
  123. package/dist-server/server/turn-resources.js +124 -0
  124. package/dist-server/shared/ask-question.js +13 -0
  125. package/dist-server/shared/call-consent.js +28 -0
  126. package/dist-server/shared/live-approval.js +124 -0
  127. package/dist-server/shared/live-call.js +176 -0
  128. package/dist-server/shared/skill-md.js +10 -0
  129. package/dist-server/shared/team-backup.js +3 -0
  130. package/dist-server/shared/tool-scope-support.js +25 -0
  131. package/dist-server/shared/tool-scope.js +136 -0
  132. package/dist-server/src/lib/thread-refs.js +406 -0
  133. package/dist-server/vps-container-mcp.js +93 -76
  134. package/dist-server/workspace-backup.worker.js +89 -100
  135. package/enterprise/server/index.js +1 -0
  136. package/package.json +1 -1
  137. package/dist/assets/channel-BU94VpxE.js +0 -1
  138. package/dist/assets/index-Biji-bfL.css +0 -1
  139. package/dist/assets/index-DZlhoES0.js +0 -597
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.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
@@ -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
@@ -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
  }
@@ -14,7 +14,6 @@ import { PROVIDER_ICON_PRESETS, providerIconError } from "../shared/provider-ico
14
14
  import { isRemoteMcpServer, parseStoredMcpServer } from "./mcp-registry.js";
15
15
  import { parseJson, schemaIssue } from "./schema.js";
16
16
  import { CLOUD_SEAT_IDLE_STOP_MS } from "./cloud-overflow.js";
17
- import { cloudHomeConfigured } from "./cloud-home.js";
18
17
  const optionalText = z.string().optional();
19
18
  const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
20
19
  const LEGACY_BROWSER_PROFILE_ID = /^[A-Za-z0-9_-]{1,40}$/;
@@ -304,6 +303,10 @@ const featureConfigSchema = z.object({
304
303
  * consent card or a configured allowlist — never a silent Auto default.
305
304
  * Off until baked; see cloudOverflowEnabled for how to enable it by hand. */
306
305
  cloudOverflow: z.boolean().optional(),
306
+ /** Opt-in shared skills library: one store at the data dir that bots
307
+ * read by assignment instead of per-workspace copies. Off until
308
+ * explicitly enabled — see skillsLibraryEnabled. */
309
+ skillsLibrary: z.boolean().optional(),
307
310
  });
308
311
  /** First-run progress. Kept in the workspace config rather than a browser so
309
312
  * it survives cleared site data and is shared by every paired client. Hint
@@ -474,6 +477,15 @@ const appConfigSchema = z.object({
474
477
  .optional(),
475
478
  jobs: z.object({ roomRouting: z.boolean().optional() }).optional(),
476
479
  }).optional(),
480
+ /** Live calls: an OpenAI project key for GPT-Live, kept apart from every
481
+ * other OpenAI credential so a Live call never bills an image or engine key
482
+ * the user did not hand to it. `voice` is a GPT-Live built-in voice name. */
483
+ live: z.object({
484
+ key: optionalText,
485
+ voice: z.string().trim().max(40).regex(/^[a-z]*$/, "a Live voice is a lowercase built-in voice name").optional(),
486
+ readTypedReplies: z.boolean().optional(),
487
+ idleMinutes: z.number().int().min(1).max(60).optional(),
488
+ }).optional(),
477
489
  /** Avatar provider credentials stay separate; choosing a router never reuses a cloud key. */
478
490
  imageGen: z.object({
479
491
  provider: z.enum(["openai", "xai", "custom"]).optional(),
@@ -630,6 +642,17 @@ export function browserEngineAttachCdpUrl(cfg) {
630
642
  export function roomTurnTimeoutMinutes(cfg) {
631
643
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
632
644
  }
645
+ export const LIVE_IDLE_MINUTES_DEFAULT = 5;
646
+ /** Non-secret Live settings. The key only shows up as `configured`. */
647
+ export function liveSettingsFor(cfg) {
648
+ const minutes = cfg.live?.idleMinutes;
649
+ return {
650
+ configured: Boolean(cfg.live?.key?.trim()),
651
+ voice: cfg.live?.voice ?? "",
652
+ readTypedReplies: cfg.live?.readTypedReplies ?? true,
653
+ idleMinutes: Number.isInteger(minutes) && minutes >= 1 && minutes <= 60 ? minutes : LIVE_IDLE_MINUTES_DEFAULT,
654
+ };
655
+ }
633
656
  /** Room handoff tree budgets in milliseconds. The tree lifetime pauses
634
657
  * while a node is actively executing; the hard cap is the maximum stall
635
658
  * window, measured wall-clock from the tree's last durable progress. Read
@@ -692,14 +715,11 @@ export function showToolCallsEnabled(cfg) {
692
715
  export function routinesInConversationEnabled(cfg) {
693
716
  return cfg.features?.routinesInConversation === true;
694
717
  }
695
- /** Workspace-level gate for the experimental built-in browser. A bot's own
696
- * switch sits under it, so either can withhold the browser. Off until the
697
- * desktop's first-run welcome turns it on. A Cloud home (cloud-home.ts)
698
- * skips that welcome, ships the browser in its image and has no other screen
699
- * of its own, so there it is on unless the person switched it off. */
700
- export function builtInBrowserEnabled(cfg, env = process.env) {
701
- const chosen = cfg.features?.browser;
702
- return chosen === undefined ? cloudHomeConfigured(env) : chosen === true;
718
+ /** Workspace-level gate for the built-in browser. A bot's own switch sits
719
+ * under it, so either can withhold the browser. On unless the person switched
720
+ * it off: an explicit `false` (Settings, or a bot's computer panel) is kept. */
721
+ export function builtInBrowserEnabled(cfg, _env = process.env) {
722
+ return cfg.features?.browser !== false;
703
723
  }
704
724
  /** Opt-in computer sharing: the routes, the agent tools, the advertised
705
725
  * capability and the desktop connector. Off unless an explicit `true` turns
@@ -766,6 +786,14 @@ export function cloudOverflowIdleStopMs(cfg) {
766
786
  export function cloudOverflowAllowlistedThreads(cfg) {
767
787
  return new Set(cfg.cloudOverflow?.allowlistedThreads ?? []);
768
788
  }
789
+ /** Opt-in shared skills library (skills lane S1): one store at the data dir
790
+ * that bots reference by assignment instead of per-workspace copies. Off
791
+ * unless enabled by hand in ~/.openmausbot/config.json
792
+ * (`{"features": {"skillsLibrary": true}}`); while off, every skills
793
+ * surface keeps today's byte-identical per-bot behavior. */
794
+ export function skillsLibraryEnabled(cfg) {
795
+ return cfg.features?.skillsLibrary === true;
796
+ }
769
797
  /** Config sections no provider driver reads. A write that touches only
770
798
  * these must not rebuild the fleet: rebuilding disposes every engine child
771
799
  * and reloads it, seconds of work that would also interrupt in-flight
@@ -778,6 +806,7 @@ export const FLEET_NEUTRAL_KEYS = new Set([
778
806
  // no engine reads it: the harness asks it before a turn starts
779
807
  "decider",
780
808
  "imageGen",
809
+ "live",
781
810
  "vps",
782
811
  "rooms",
783
812
  "threads",
@@ -937,6 +966,9 @@ export function loadConfig() {
937
966
  cfg.decider = { ...cfg.decider };
938
967
  if (process.env.OMB_JEV_API_KEY !== undefined)
939
968
  cfg.decider.key = process.env.OMB_JEV_API_KEY;
969
+ cfg.live = { ...cfg.live };
970
+ if (process.env.OMB_OPENAI_LIVE_KEY !== undefined)
971
+ cfg.live.key = process.env.OMB_OPENAI_LIVE_KEY;
940
972
  cfg.imageGen = { ...cfg.imageGen };
941
973
  if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
942
974
  cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
@@ -978,6 +1010,7 @@ export function syncCredentialEnv(patch) {
978
1010
  [patch.decider?.key, "OMB_JEV_API_KEY"],
979
1011
  [patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
980
1012
  [patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
1013
+ [patch.live?.key, "OMB_OPENAI_LIVE_KEY"],
981
1014
  ];
982
1015
  for (const [value, name] of secrets) {
983
1016
  if (value === undefined)
@@ -1028,6 +1061,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
1028
1061
  "OMB_JEV_API_KEY",
1029
1062
  "OMB_OPENAI_IMAGE_KEY",
1030
1063
  "OMB_CUSTOM_IMAGE_KEY",
1064
+ "OMB_OPENAI_LIVE_KEY",
1031
1065
  "COMPOSIO_API_KEY",
1032
1066
  "OMB_COMPOSIO_BROKER_TOKEN",
1033
1067
  // Cloud Pro's included Boat, voice and decision relay tokens
@@ -1118,7 +1152,7 @@ export function saveConfig(patch, options = {}) {
1118
1152
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
1119
1153
  if (storedProfiles.success)
1120
1154
  disk.browserProfiles = storedProfiles.data;
1121
- for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1155
+ for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "live", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1122
1156
  const section = checkedPatch[key];
1123
1157
  if (!section)
1124
1158
  continue;