openmausbot 0.1.89 → 0.1.90

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/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-DUyXd0kL.js} +1 -1
  2. package/dist/assets/{arc-Be-8GKxd.js → arc-q73qM-At.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-D33O3Wur.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DLsJeoXC.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-DnaljZnu.js} +1 -1
  6. package/dist/assets/channel-BCuyHjFV.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-Da0h2i79.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-CfzHi54f.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-LjE4d9Jf.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DY-UGmvc.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-BqeGhyhP.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BYa9EBAg.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-vTrDtWyb.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-ic5tXadM.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-DvRCY-c6.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-DVbmgz_L.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-vdUs2n57.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-BvLGlV3U.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-C4GcJQDt.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-BMSaHpt_.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-D5b9UBhC.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-CIKLlB7Y.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BzXb-7k7.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-HsTvJ7uT.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-Db9ySNbQ.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-D6BKbpqY.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-Bq067L0t.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-DhaD7fNJ.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-fwIhfz36.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-DygIyu0V.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-DPYfSO96.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-DhkHeCle.js} +1 -1
  33. package/dist/assets/index-CRvo-DS2.css +1 -0
  34. package/dist/assets/index-CWYGlA63.js +588 -0
  35. package/dist/assets/{index-Oxk9x45i.js → index-G1PHKGyh.js} +1 -1
  36. package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-DstBLxME.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-CrIsGgch.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-CYypGDbB.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DGjPt8E_.js} +1 -1
  40. package/dist/assets/{linear-CtJnKe6A.js → linear-Dwc3Hrru.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-Dc4OePBL.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-DV-vepj-.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-lx5Kj-n5.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-Bq7Vmm15.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-DeqeqOBj.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-CXwRQzSC.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-8tAeoB88.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-BG5zjGWU.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-DKF3iLcw.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-B-y1qUgT.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-MkzPgxAc.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DnbaOEwH.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-Bb4xG_Bt.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-JgkR9pFf.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-CIPIrAYe.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-BjCLuUvJ.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-DKgiaYxT.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/cloud-home-start.js +14795 -0
  61. package/dist-server/container-mcp.js +52 -4
  62. package/dist-server/drivers/agents-proxy.js +20 -10
  63. package/dist-server/index.js +8985 -6461
  64. package/dist-server/openmausbot.js +327 -145
  65. package/dist-server/pair-cli.js +327 -145
  66. package/dist-server/permission-proxy.js +1 -1
  67. package/dist-server/server/admission.js +46 -0
  68. package/dist-server/server/{box-create-idempotency.js → boat-create-idempotency.js} +32 -31
  69. package/dist-server/server/{box-delete-journal.js → boat-delete-journal.js} +26 -25
  70. package/dist-server/server/{box.js → boat.js} +330 -328
  71. package/dist-server/server/bot-profile.js +7 -1
  72. package/dist-server/server/channel-queue.js +58 -18
  73. package/dist-server/server/checkpoints.js +98 -52
  74. package/dist-server/server/claim-idle.js +33 -0
  75. package/dist-server/server/cloud-backend.js +5 -5
  76. package/dist-server/server/cloud-home-start.js +103 -0
  77. package/dist-server/server/cloud-home.js +218 -0
  78. package/dist-server/server/cloud-overflow.js +167 -0
  79. package/dist-server/server/composio.js +5 -0
  80. package/dist-server/server/computer-observation.js +2 -2
  81. package/dist-server/server/config.js +115 -6
  82. package/dist-server/server/container-computer.js +13 -0
  83. package/dist-server/server/control-client.js +2 -2
  84. package/dist-server/server/delegations.js +11 -3
  85. package/dist-server/server/drivers/acp/core.js +5 -4
  86. package/dist-server/server/drivers/agents-call.js +5 -1
  87. package/dist-server/server/drivers/agents-catalog.js +9 -3
  88. package/dist-server/server/drivers/{boxagent.js → boatagent.js} +13 -12
  89. package/dist-server/server/drivers/builtIn.js +2 -2
  90. package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
  91. package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
  92. package/dist-server/server/drivers/claude.js +177 -13
  93. package/dist-server/server/drivers/native.js +1 -1
  94. package/dist-server/server/drivers/openai-chat.js +15 -4
  95. package/dist-server/server/drivers/pi.js +3 -3
  96. package/dist-server/server/hosted-models.js +6 -1
  97. package/dist-server/server/index.js +1029 -479
  98. package/dist-server/server/local-vm-inventory.js +11 -1
  99. package/dist-server/server/local-vm-seat-pool.js +141 -0
  100. package/dist-server/server/managed-policy.js +4 -1
  101. package/dist-server/server/memory-capture.js +194 -0
  102. package/dist-server/server/memory-entries.js +76 -0
  103. package/dist-server/server/memory-organize.js +102 -0
  104. package/dist-server/server/memory-tidy.js +144 -0
  105. package/dist-server/server/memory-topics.js +169 -0
  106. package/dist-server/server/memory-upkeep.js +452 -0
  107. package/dist-server/server/message-db.js +49 -5
  108. package/dist-server/server/permission-proxy.js +3 -3
  109. package/dist-server/server/profile-learned.js +98 -0
  110. package/dist-server/server/profile-versions.js +45 -11
  111. package/dist-server/server/recall.js +172 -0
  112. package/dist-server/server/redact.js +1 -1
  113. package/dist-server/server/room-cwd.js +1 -1
  114. package/dist-server/server/room-handoffs.js +61 -6
  115. package/dist-server/server/routes/bot-memory.js +215 -0
  116. package/dist-server/server/routine-requests.js +27 -16
  117. package/dist-server/server/routines.js +10 -6
  118. package/dist-server/server/steer-queue.js +41 -10
  119. package/dist-server/server/store.js +25 -1
  120. package/dist-server/server/surface.js +1 -1
  121. package/dist-server/server/system-prompt.js +5 -5
  122. package/dist-server/server/team-computers.js +2 -2
  123. package/dist-server/server/tightening-requests.js +34 -4
  124. package/dist-server/server/tool-summary.js +2 -2
  125. package/dist-server/server/tts/elevenlabs.js +1 -1
  126. package/dist-server/server/turn-resources.js +87 -11
  127. package/dist-server/server/vps-computer.js +1 -1
  128. package/dist-server/server/workspace.js +57 -22
  129. package/dist-server/shared/ask-question.js +7 -5
  130. package/dist-server/shared/bot-avatar.js +19 -0
  131. package/dist-server/shared/credential-request.js +3 -3
  132. package/dist-server/shared/tool-surface.js +1 -1
  133. package/dist-server/src/lib/composer-attachments.js +9 -0
  134. package/dist-server/vps-container-mcp.js +52 -4
  135. package/dist-server/workspace-backup.worker.js +52 -4
  136. package/package.json +1 -1
  137. package/dist/assets/channel-DqY_gMLg.js +0 -1
  138. package/dist/assets/index-BIMdIbaw.css +0 -1
  139. package/dist/assets/index-BVPdbq2r.js +0 -583
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +0 -8
@@ -0,0 +1,218 @@
1
+ // OMB Cloud Pro home machine: the boot contract, the Admin's signed pairing
2
+ // request, and the volume the machine lives on. docs/cloud-pro.md is the
3
+ // contract of record (and openmaus-cloud docs/consumer-cloud.md its Admin
4
+ // half); keep them in step.
5
+ //
6
+ // One Fly app per customer runs this server behind an edge proxy on
7
+ // 0.0.0.0:8080 (server/cloud-home-start.ts). The server itself still binds
8
+ // 127.0.0.1 and every request reaches it with X-Forwarded-* headers, so no
9
+ // request from the network is ever the loopback owner.
10
+ //
11
+ // The Admin never holds a session on the machine. It signs a request with
12
+ // the machine's bootstrap secret (HMAC-SHA256 over the method, path, a
13
+ // timestamp, a nonce and the body's hash); each valid request opens one
14
+ // ordinary pairing window (sessions.ts: single use, at most ten minutes),
15
+ // which the Admin hands to the person's signed-in desktop app.
16
+ //
17
+ // Cloud Pro includes no AI. The person signs in on the machine with their own
18
+ // Claude or ChatGPT account, or an API key, exactly as on any server; nothing
19
+ // on a Cloud home is ever routed to a platform model gateway.
20
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { writeFileAtomic } from "./atomic.js";
24
+ import { hostedWorkspaceConfigured } from "./enterprise.js";
25
+ import { formatPairingCode } from "./sessions.js";
26
+ export const CLOUD_HOME_CONTRACT_VERSION = 1;
27
+ /** Any of these switches the server into Cloud home mode; then all are required. */
28
+ export const CLOUD_HOME_KEYS = ["OMB_CLOUD_ROLE", "OMB_CLOUD_MACHINE_ID", "OMB_CLOUD_ADMIN_URL", "OMB_CLOUD_BOOTSTRAP_SECRET"];
29
+ /** A platform model gateway's settings. A Cloud home never uses them: given
30
+ * any, it logs one warning, and neither the server nor anything it starts
31
+ * ever sees them. */
32
+ export const CLOUD_IGNORED_KEYS = ["OMB_HOSTED_MODEL_URL", "OMB_HOSTED_MODEL_TOKEN", "OMB_HOSTED_MODELS"];
33
+ export const CLOUD_PAIRING_PATH = "/api/cloud/pairing";
34
+ export const CLOUD_PAIRING_DEFAULT_TTL_S = 300;
35
+ export const CLOUD_PAIRING_MAX_TTL_S = 600;
36
+ /** How far a signed request's timestamp may be from this machine's clock. */
37
+ export const CLOUD_PAIRING_SKEW_S = 300;
38
+ /** How long a used nonce is refused. Longer than the whole accepted window. */
39
+ export const CLOUD_PAIRING_NONCE_MS = 10 * 60_000;
40
+ const MAX_NONCES = 10_000;
41
+ export const CLOUD_HOME_MARKER = ".omb-cloud-home.json";
42
+ export function cloudHomeConfigured(env = process.env) {
43
+ return CLOUD_HOME_KEYS.some((key) => env[key] !== undefined);
44
+ }
45
+ function invalid(why) {
46
+ throw new Error(`Cloud home configuration is invalid: ${why}.`);
47
+ }
48
+ function exactHttpsOrigin(raw, name) {
49
+ let url;
50
+ try {
51
+ if (!raw || raw !== raw.trim())
52
+ throw new Error();
53
+ url = new URL(raw);
54
+ }
55
+ catch {
56
+ return invalid(`${name} must be an https origin`);
57
+ }
58
+ if (url.protocol !== "https:" || url.username || url.password || (raw !== url.origin && raw !== `${url.origin}/`)) {
59
+ invalid(`${name} must be an exact https origin with no path`);
60
+ }
61
+ return url.origin;
62
+ }
63
+ /** Read the boot contract. Null on any machine that is not a Cloud home;
64
+ * throws (so the server refuses to start) on a partial or invalid one. */
65
+ export function cloudHomeConfiguration(env = process.env) {
66
+ if (!cloudHomeConfigured(env))
67
+ return null;
68
+ if (env.OMB_DESKTOP_PARENT === "1")
69
+ invalid("the desktop app cannot run as a Cloud home machine");
70
+ if (hostedWorkspaceConfigured(env))
71
+ invalid("a Cloud home is not a hosted team workspace; remove OMB_ADMIN_URL, OMB_ADMIN_WORKSPACE and OMB_ADMIN_MEMBERSHIP");
72
+ if (env.OMB_CLOUD_ROLE !== "home")
73
+ invalid('OMB_CLOUD_ROLE must be "home"; this image runs the home machine');
74
+ const machineId = env.OMB_CLOUD_MACHINE_ID ?? "";
75
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/.test(machineId))
76
+ invalid("OMB_CLOUD_MACHINE_ID must be the Admin's machine id (letters, digits, dashes)");
77
+ const adminOrigin = exactHttpsOrigin(env.OMB_CLOUD_ADMIN_URL, "OMB_CLOUD_ADMIN_URL");
78
+ const publicOrigin = exactHttpsOrigin(env.OMB_PUBLIC_URL, "OMB_PUBLIC_URL");
79
+ const bootstrapSecret = env.OMB_CLOUD_BOOTSTRAP_SECRET ?? "";
80
+ if (!/^[A-Za-z0-9_-]{43,128}$/.test(bootstrapSecret))
81
+ invalid("OMB_CLOUD_BOOTSTRAP_SECRET must be at least 256 bits of base64url");
82
+ const warnings = [];
83
+ const ignored = CLOUD_IGNORED_KEYS.filter((key) => env[key] !== undefined);
84
+ if (ignored.length)
85
+ warnings.push(`ignoring ${ignored.join(", ")}: Cloud Pro includes no AI; people sign in with their own Claude or ChatGPT account, or an API key`);
86
+ return { machineId, adminOrigin, publicOrigin, bootstrapSecret, warnings };
87
+ }
88
+ /** The environment without a platform gateway's settings (CLOUD_IGNORED_KEYS). */
89
+ export function withoutIgnoredCloudKeys(env) {
90
+ const kept = { ...env };
91
+ for (const key of CLOUD_IGNORED_KEYS)
92
+ delete kept[key];
93
+ return kept;
94
+ }
95
+ /** The Admin's side of the signature (openmaus-cloud cloudPairingSignature). */
96
+ export function cloudPairingSignature(secret, timestamp, nonce, body) {
97
+ const bodyHash = createHash("sha256").update(body).digest("base64url");
98
+ return createHmac("sha256", secret).update(`v1\n${timestamp}\n${nonce}\nPOST\n${CLOUD_PAIRING_PATH}\n${bodyHash}`).digest("base64url");
99
+ }
100
+ /** The Admin's signed request for one pairing window on this machine. */
101
+ export function createCloudPairing(options) {
102
+ const { secret, sessions } = options;
103
+ const now = options.now ?? Date.now;
104
+ const nonces = new Map();
105
+ const refused = (error) => ({ status: 401, body: { error } });
106
+ return {
107
+ /** Everything is checked before anything is opened: the signature (in
108
+ * constant time; a bad one counts toward the pairing lockout), then the
109
+ * clock, then the nonce, then the body. Nothing here is ever logged. */
110
+ handle(input) {
111
+ const lock = sessions.attemptAllowed(input.source);
112
+ if (!lock.ok)
113
+ return { status: 429, body: { error: "rate_limited", retryAfterSeconds: Math.ceil(lock.retryAfterMs / 1000) } };
114
+ const timestamp = input.timestamp ?? "", nonce = input.nonce ?? "";
115
+ const presented = /^v1=([A-Za-z0-9_-]{43})$/.exec(input.signature ?? "")?.[1];
116
+ const expected = Buffer.from(cloudPairingSignature(secret, timestamp, nonce, input.body), "base64url");
117
+ const given = presented ? Buffer.from(presented, "base64url") : Buffer.alloc(expected.length);
118
+ const matches = given.length === expected.length && timingSafeEqual(given, expected);
119
+ if (!presented || !matches || !/^\d{1,12}$/.test(timestamp) || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
120
+ sessions.noteFailure(input.source);
121
+ return refused("invalid_signature");
122
+ }
123
+ sessions.clearFailures(input.source);
124
+ const at = now();
125
+ if (Math.abs(at / 1000 - Number(timestamp)) > CLOUD_PAIRING_SKEW_S)
126
+ return refused("stale_request");
127
+ for (const [seen, until] of nonces)
128
+ if (until <= at)
129
+ nonces.delete(seen);
130
+ if (nonces.has(nonce))
131
+ return refused("replayed_request");
132
+ if (nonces.size >= MAX_NONCES)
133
+ return { status: 503, body: { error: "busy" } };
134
+ nonces.set(nonce, at + CLOUD_PAIRING_NONCE_MS);
135
+ let parsed;
136
+ try {
137
+ parsed = input.body.length ? JSON.parse(input.body.toString("utf8")) : {};
138
+ }
139
+ catch {
140
+ return { status: 400, body: { error: "invalid_body" } };
141
+ }
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
143
+ return { status: 400, body: { error: "invalid_body" } };
144
+ const { label, ttlSeconds } = parsed;
145
+ // oxlint-disable-next-line no-control-regex
146
+ if (label !== undefined && (typeof label !== "string" || label.length > 80 || /[\x00-\x1f\x7f]/.test(label)))
147
+ return { status: 400, body: { error: "invalid_label" } };
148
+ if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1))
149
+ return { status: 400, body: { error: "invalid_ttl" } };
150
+ const ttl = Math.min(ttlSeconds ?? CLOUD_PAIRING_DEFAULT_TTL_S, CLOUD_PAIRING_MAX_TTL_S);
151
+ const opened = sessions.openPairing({
152
+ scopes: ["admin", "client"],
153
+ label: typeof label === "string" && label.trim() ? label.trim() : "OMB Cloud",
154
+ ttlMs: ttl * 1000,
155
+ });
156
+ return { status: 200, body: { code: formatPairingCode(opened.code), credential: opened.credential, expiresAt: opened.expiresAt } };
157
+ },
158
+ };
159
+ }
160
+ /** The exact bytes the Admin signed, bounded. */
161
+ export function readSignedBody(req, limit = 4096) {
162
+ return new Promise((resolve, reject) => {
163
+ const chunks = [];
164
+ let size = 0, done = false;
165
+ req.on("data", (chunk) => {
166
+ if (done)
167
+ return;
168
+ size += chunk.length;
169
+ if (size > limit) {
170
+ done = true;
171
+ reject(Object.assign(new Error("body too large"), { status: 413 }));
172
+ return;
173
+ }
174
+ chunks.push(chunk);
175
+ });
176
+ req.on("end", () => { if (!done) {
177
+ done = true;
178
+ resolve(Buffer.concat(chunks));
179
+ } });
180
+ req.on("error", (error) => { if (!done) {
181
+ done = true;
182
+ reject(Object.assign(error, { status: 400 }));
183
+ } });
184
+ });
185
+ }
186
+ // The image's home directory may carry the shell files useradd seeds; a
187
+ // fresh Fly volume carries lost+found. Anything else is someone's data.
188
+ const ADOPTABLE = new Set(["lost+found", ".bash_logout", ".bashrc", ".profile"]);
189
+ /** Bind the volume to this machine on first boot and refuse any other
190
+ * machine's volume afterwards. Never deletes or rewrites existing data. */
191
+ export function prepareCloudHomeVolume(home, machineId) {
192
+ mkdirSync(home, { recursive: true, mode: 0o700 });
193
+ const marker = join(home, CLOUD_HOME_MARKER);
194
+ if (existsSync(marker)) {
195
+ const stat = lstatSync(marker);
196
+ let saved;
197
+ try {
198
+ if (!stat.isFile() || stat.size > 4096)
199
+ throw new Error();
200
+ saved = JSON.parse(readFileSync(marker, "utf8"));
201
+ }
202
+ catch {
203
+ throw new Error("This volume's Cloud marker is unreadable. No data was changed.");
204
+ }
205
+ if (saved?.machine !== machineId)
206
+ throw new Error("This volume belongs to another Cloud machine. No data was changed.");
207
+ return "existing";
208
+ }
209
+ if (readdirSync(home).some((name) => !ADOPTABLE.has(name))) {
210
+ throw new Error("Refusing to adopt a data volume that was not made for this Cloud machine. No data was changed.");
211
+ }
212
+ writeFileAtomic(marker, JSON.stringify({ version: 1, machine: machineId }) + "\n", { mode: 0o600 });
213
+ return "new";
214
+ }
215
+ /** What the edge proxy may answer for: the machine's own public name. */
216
+ export function cloudHomeHost(config) {
217
+ return new URL(config.publicOrigin).hostname;
218
+ }
@@ -0,0 +1,167 @@
1
+ /** Consent, cost, and idle-stop policy for overflowing a local computer
2
+ * wait onto a per-second-billed cloud seat (#1655).
3
+ *
4
+ * Like server/claim-idle.ts this module is deliberately synchronous and
5
+ * clock-injected: the server decides every transition on its single
6
+ * thread and tests pin time exactly. Nothing here starts or stops a
7
+ * machine — the wiring does, and only when the decision says to. Keep
8
+ * this module free of server imports. */
9
+ import { computerWaitDuration } from "./computer-wait.js";
10
+ /** Default idle stop: a cloud seat no real screen work has touched for
11
+ * this long is stopped (archived), because billing runs per second while
12
+ * it runs. "A few idle minutes" per the issue. */
13
+ export const CLOUD_SEAT_IDLE_STOP_MS = 5 * 60_000;
14
+ /** Retry cadence for an idle stop that failed (#1655): the first retry
15
+ * after one minute, doubling per failure up to ten. A failed stop keeps
16
+ * its lease — the seat still bills — so the sweep always retries instead
17
+ * of abandoning a running machine. */
18
+ export const CLOUD_SEAT_SLEEP_RETRY_MS = 60_000;
19
+ export const CLOUD_SEAT_SLEEP_RETRY_MAX_MS = 10 * 60_000;
20
+ export function cloudOverflowAction(situation) {
21
+ if (!situation.featureEnabled || !situation.cloudConfigured || situation.perSecondCostUsd === null)
22
+ return { kind: "off" };
23
+ if (situation.started)
24
+ return { kind: "none" };
25
+ if (situation.consented)
26
+ return { kind: "start" };
27
+ if (situation.offered)
28
+ return { kind: "none" };
29
+ return { kind: "offer" };
30
+ }
31
+ /** Per-conversation consent plus the offer bookkeeping that keeps the
32
+ * card to one per conversation until it is revoked. A thread the operator
33
+ * allowlisted in config carries standing consent and never needs the
34
+ * card. */
35
+ export class CloudOverflowConsent {
36
+ grants = new Map();
37
+ offers = new Map();
38
+ revocations = new Set();
39
+ /** Consent for this conversation. With `perSecondCostUsd`, a card
40
+ * consent counts only at the rate its card showed — an allowlisted
41
+ * thread carries the operator's standing consent, and the operator is
42
+ * also the one who sets the rate. */
43
+ consented(threadId, allowlistedThreads = new Set(), perSecondCostUsd) {
44
+ if (this.revocations.has(threadId))
45
+ return false;
46
+ if (allowlistedThreads.has(threadId))
47
+ return true;
48
+ const grant = this.grants.get(threadId);
49
+ if (!grant)
50
+ return false;
51
+ return perSecondCostUsd === undefined || grant.perSecondCostUsd === perSecondCostUsd;
52
+ }
53
+ /** `perSecondCostUsd` is the rate the answered card showed. */
54
+ grant(threadId, perSecondCostUsd, now = Date.now()) {
55
+ this.revocations.delete(threadId);
56
+ this.grants.set(threadId, { at: now, perSecondCostUsd });
57
+ }
58
+ /** Revoking also clears the offered mark, so a later wait in the same
59
+ * conversation may ask again instead of meeting silence. */
60
+ revoke(threadId) {
61
+ const had = this.grants.delete(threadId);
62
+ this.revocations.add(threadId);
63
+ this.offers.delete(threadId);
64
+ return had;
65
+ }
66
+ /** A card is outstanding for this conversation — at the given rate
67
+ * when one is supplied, so a config change re-offers with the new
68
+ * price instead of meeting silence at the old one. */
69
+ offered(threadId, perSecondCostUsd) {
70
+ const offer = this.offers.get(threadId);
71
+ if (!offer)
72
+ return false;
73
+ return perSecondCostUsd === undefined || offer.perSecondCostUsd === perSecondCostUsd;
74
+ }
75
+ /** The rate the outstanding card showed, or null when no card is out. */
76
+ offeredRate(threadId) {
77
+ return this.offers.get(threadId)?.perSecondCostUsd ?? null;
78
+ }
79
+ markOffered(threadId, perSecondCostUsd, now = Date.now()) {
80
+ this.offers.set(threadId, { at: now, perSecondCostUsd });
81
+ }
82
+ }
83
+ /** One running cloud seat started by overflow. `touch` is called only
84
+ * for real computer tool completions on that seat — screen-poller frames
85
+ * never reach it, exactly like #1653's activity clock, so preview
86
+ * traffic cannot keep a paid machine awake. */
87
+ export class CloudSeatLease {
88
+ botId;
89
+ threadId;
90
+ /** The claim generation that started the seat: a stop releases the Box
91
+ * claim for exactly this owner, never a newer turn's. */
92
+ generation;
93
+ startedAt;
94
+ idleStopMs;
95
+ lastActivityAt;
96
+ sleepRetryNotBefore = 0;
97
+ sleepRetryMs = CLOUD_SEAT_SLEEP_RETRY_MS;
98
+ constructor(init) {
99
+ const idleStopMs = init.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
100
+ if (!Number.isFinite(idleStopMs) || idleStopMs <= 0)
101
+ throw new Error("Cloud seat idle stop window must be positive");
102
+ this.botId = init.botId;
103
+ this.threadId = init.threadId;
104
+ this.generation = init.generation;
105
+ this.startedAt = init.now ?? Date.now();
106
+ this.lastActivityAt = this.startedAt;
107
+ this.idleStopMs = idleStopMs;
108
+ }
109
+ touch(now = Date.now()) {
110
+ this.lastActivityAt = now;
111
+ // Real work earns a fresh stop attempt: the backoff only paces
112
+ // retries against an idle seat that keeps failing to sleep.
113
+ this.sleepRetryNotBefore = 0;
114
+ this.sleepRetryMs = CLOUD_SEAT_SLEEP_RETRY_MS;
115
+ }
116
+ /** An idle stop may run now — true until a failure defers it. */
117
+ sleepDue(now = Date.now()) {
118
+ return now >= this.sleepRetryNotBefore;
119
+ }
120
+ /** The idle stop failed: keep the lease and back off, bounded, so the
121
+ * sweep retries gently instead of hammering a sick Box — and never
122
+ * abandons a seat that still bills. */
123
+ deferSleep(now = Date.now()) {
124
+ this.sleepRetryNotBefore = now + this.sleepRetryMs;
125
+ this.sleepRetryMs = Math.min(this.sleepRetryMs * 2, CLOUD_SEAT_SLEEP_RETRY_MAX_MS);
126
+ }
127
+ idleFor(now = Date.now()) {
128
+ return Math.max(0, now - this.lastActivityAt);
129
+ }
130
+ idleElapsed(now = Date.now()) {
131
+ return now - this.lastActivityAt >= this.idleStopMs;
132
+ }
133
+ }
134
+ /** Dollars per second at rate-card precision: enough places for small
135
+ * per-second rates to stay visible, trailing zeros trimmed but never
136
+ * past two decimals. */
137
+ export function formatPerSecondUsd(perSecondCostUsd) {
138
+ let text = perSecondCostUsd.toFixed(6).replace(/0+$/, "");
139
+ if (text.endsWith("."))
140
+ text += "00";
141
+ else {
142
+ const decimals = text.length - text.indexOf(".") - 1;
143
+ if (decimals < 2)
144
+ text += "0".repeat(2 - decimals);
145
+ }
146
+ return "$" + text;
147
+ }
148
+ /** The consent card (#1655): the per-second cost sits beside the local
149
+ * wait picture before any choice is made, and nothing starts without an
150
+ * explicit consent the person still has to give. */
151
+ export function cloudOverflowOfferText(opts) {
152
+ const idleStopMs = opts.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
153
+ const estimate = opts.waitEstimateMs === undefined ? "" : ` Recent local waits here have taken about ${computerWaitDuration(opts.waitEstimateMs)}.`;
154
+ return `This computer is busy. Its work can overflow to a cloud computer for ${formatPerSecondUsd(opts.perSecondCostUsd)} per second.${estimate} Nothing starts without your consent — allow cloud overflow for this conversation to start it; the cloud computer stops after ${computerWaitDuration(idleStopMs)} idle.`;
155
+ }
156
+ export function cloudSeatStartedText(opts) {
157
+ const idleStopMs = opts.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
158
+ return `Cloud computer started at ${formatPerSecondUsd(opts.perSecondCostUsd)} per second. It stops automatically after ${computerWaitDuration(idleStopMs)} idle, and the local wait continues until this computer is free.`;
159
+ }
160
+ export function cloudSeatStoppedText(idleMs) {
161
+ return `Cloud computer stopped after ${computerWaitDuration(idleMs)} idle — billing pauses while it sleeps.`;
162
+ }
163
+ export function cloudOverflowConsentText(allowed, idleStopMs = CLOUD_SEAT_IDLE_STOP_MS) {
164
+ return allowed
165
+ ? `Cloud overflow allowed for this conversation. A waiting turn may start the cloud computer; it stops after ${computerWaitDuration(idleStopMs)} idle.`
166
+ : "Cloud overflow consent revoked. Waits stay local, and no cloud computer starts without a new consent.";
167
+ }
@@ -196,6 +196,11 @@ export function connectorAvailability(cfg, storeState = process.env.OMB_CREDENTI
196
196
  return "configured";
197
197
  return storeState === "unavailable" ? "unreadable" : "unconfigured";
198
198
  }
199
+ export function connectorSetup(cfg, desktopManaged = process.env.OMB_DESKTOP_PARENT === "1") {
200
+ if (configured(cfg))
201
+ return "ready";
202
+ return desktopManaged ? "service-unavailable" : "needs-setup";
203
+ }
199
204
  async function brokerRequest(path, init) {
200
205
  const broker = brokerAccess();
201
206
  if (!broker)
@@ -99,7 +99,7 @@ export class ObservationCoordinator {
99
99
  this.metrics.retries += 1;
100
100
  }
101
101
  /** canonicalFrame must describe the full screenshot, even when the image
102
- * returned to the model is cropped. A box-provided full-frame hash works. */
102
+ * returned to the model is cropped. A boat-provided full-frame hash works. */
103
103
  observeFrame(canonicalFrame, crop) {
104
104
  this.metrics.screenshotsCaptured += 1;
105
105
  const hash = canonicalFrame
@@ -107,7 +107,7 @@ export class ObservationCoordinator {
107
107
  : null;
108
108
  const view = crop ? `${crop.x},${crop.y},${crop.width},${crop.height}` : "full";
109
109
  const signature = hash ? `${hash}:${view}` : null;
110
- // If the box cannot provide a full-frame hash, fail open and send the
110
+ // If the boat cannot provide a full-frame hash, fail open and send the
111
111
  // valid image. Suppressing a possibly-new crop would be worse.
112
112
  const changed = signature === null || signature !== this.lastObservation;
113
113
  if (signature)
@@ -13,6 +13,7 @@ import { isModelVariant } from "./contracts.js";
13
13
  import { PROVIDER_ICON_PRESETS, providerIconError } from "../shared/provider-icon.js";
14
14
  import { isRemoteMcpServer, parseStoredMcpServer } from "./mcp-registry.js";
15
15
  import { parseJson, schemaIssue } from "./schema.js";
16
+ import { CLOUD_SEAT_IDLE_STOP_MS } from "./cloud-overflow.js";
16
17
  const optionalText = z.string().optional();
17
18
  const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
18
19
  const LEGACY_BROWSER_PROFILE_ID = /^[A-Za-z0-9_-]{1,40}$/;
@@ -87,7 +88,8 @@ const roomConfigSchema = z.object({
87
88
  .min(MIN_ROOM_TURN_TIMEOUT_MINUTES)
88
89
  .max(MAX_ROOM_TURN_TIMEOUT_MINUTES),
89
90
  /** Room handoff tree lifetime. Active execution pauses this clock; the
90
- * hard cap is wall-clock and bounds trees that never stop executing. */
91
+ * hard cap is the maximum stall window, measured wall-clock from the
92
+ * tree's last durable progress. */
91
93
  handoffLifetimeMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
92
94
  handoffMinRunwayMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
93
95
  handoffHardCapMinutes: z.number().int().min(1).max(7 * MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
@@ -95,8 +97,14 @@ const roomConfigSchema = z.object({
95
97
  (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) &&
96
98
  (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) <=
97
99
  (rooms.handoffHardCapMinutes ?? DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES), { message: "rooms handoff bounds must satisfy handoffMinRunwayMinutes <= handoffLifetimeMinutes <= handoffHardCapMinutes" });
100
+ /** Isolation for bot desktops. Migration note (issue #1654): switching
101
+ * modes changes lease keys and vm-home directories, so desktops cold-start
102
+ * under the new mode — a pool seat lives in vm-homes/pool-N — while the old
103
+ * mode's workspaces stay on disk until removed. maxInstances caps per-bot
104
+ * desktops in per-bot mode, or the pool's seat count in pool mode; it is
105
+ * not a total across modes. Default stays "shared". */
98
106
  const localVmConfigSchema = z.object({
99
- mode: z.enum(["shared", "per-bot"]).optional(),
107
+ mode: z.enum(["shared", "per-bot", "pool"]).optional(),
100
108
  maxInstances: z
101
109
  .number()
102
110
  .int()
@@ -247,6 +255,10 @@ const featureConfigSchema = z.object({
247
255
  skillAuthoring: z.boolean().optional(),
248
256
  /** Show each tool run in the transcript. Off unless explicitly enabled. */
249
257
  showToolCalls: z.boolean().optional(),
258
+ /** Run a routine inside the conversation it reports to, instead of a hidden
259
+ * thread. Off unless explicitly enabled. The run still starts from its own
260
+ * instructions, but the messages stay in that chat. */
261
+ routinesInConversation: z.boolean().optional(),
250
262
  /** Experimental built-in browser. Off until explicitly enabled; each bot
251
263
  * also has its own switch. */
252
264
  browser: z.boolean().optional(),
@@ -260,8 +272,23 @@ const featureConfigSchema = z.object({
260
272
  claudeUserMcp: z.boolean().optional(),
261
273
  /** LLM-generated titles for new bot threads. Off until explicitly
262
274
  * enabled; a one-shot that fails or answers junk leaves the first-message
263
- * snippet in place — see llmThreadTitlesEnabled. */
275
+ * snippet in place — see llmThreadTitlesEnabled. */
264
276
  llmThreadTitles: z.boolean().optional(),
277
+ /** Before each turn, passages from the bot's own memory files and earlier
278
+ * conversations that share words with the message ride into the turn.
279
+ * Read-only; on unless explicitly switched off — see autoRecallEnabled. */
280
+ autoRecall: z.boolean().optional(),
281
+ /** Idle release for computer claims (#1653): a desktop seat that stays
282
+ * screen-quiet for 90 seconds is released to waiting turns while its
283
+ * holder's turn still lives; the previous holder re-claims directly
284
+ * for 10 minutes and yields to an occupied seat. Off until baked; see
285
+ * computerClaimIdleReleaseEnabled for how to enable it by hand. */
286
+ computerClaimIdleRelease: z.boolean().optional(),
287
+ /** Consented cloud overflow for local computer waits (#1655): a wait
288
+ * may offer a per-second-billed cloud seat, but only through an explicit
289
+ * consent card or a configured allowlist — never a silent Auto default.
290
+ * Off until baked; see cloudOverflowEnabled for how to enable it by hand. */
291
+ cloudOverflow: z.boolean().optional(),
265
292
  });
266
293
  /** First-run progress. Kept in the workspace config rather than a browser so
267
294
  * it survives cleared site data and is shared by every paired client. Hint
@@ -376,6 +403,8 @@ const appConfigSchema = z.object({
376
403
  /** Project key used for Sessions, catalog and agent tools. userId/sessionId
377
404
  * are non-secret local identifiers used to reuse one Composio Session. */
378
405
  composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(),
406
+ /** Historical config section name "box" (the persisted config.json key); the
407
+ * provider is Boat now and the key is kept for compatibility. */
379
408
  box: z.object({ token: optionalText }).optional(),
380
409
  vps: vpsConfigSchema.optional(),
381
410
  /** Optional OpenCode key; persisted write-only and passed only to its child. */
@@ -429,10 +458,26 @@ const appConfigSchema = z.object({
429
458
  compactAt: z.number().positive().max(10_000_000).optional(),
430
459
  autoCompact: z.boolean().optional(),
431
460
  }).optional(),
461
+ /** Memory upkeep timing, for bots with Memory upkeep switched on. */
462
+ memory: z.object({
463
+ /** Quiet time after a turn before its facts are captured (ms). */
464
+ captureQuietMs: z.number().int().min(1_000).max(24 * 60 * 60_000).optional(),
465
+ /** Local hour (0-23) after which the nightly tidy-up runs. */
466
+ tidyHour: z.number().int().min(0).max(23).optional(),
467
+ }).strict().optional(),
432
468
  threads: threadsConfigSchema.optional(),
433
469
  /** The authorization decision log (server/decision-log.ts): days of month
434
470
  * files kept, at least; OMB_DECISION_RETENTION_DAYS wins when set. */
435
471
  decisions: z.object({ retentionDays: z.number().int().min(1).max(3650).optional() }).strict().optional(),
472
+ /** #1655 cloud-overflow settings. perSecondCostUsd is the operator's own
473
+ * verified rate: with no price configured the feature stays inert rather
474
+ * than show an invented one. allowlistedThreads carries standing consent
475
+ * for exact thread ids. */
476
+ cloudOverflow: z.object({
477
+ perSecondCostUsd: z.number().positive().max(10).optional(),
478
+ idleStopMs: z.number().int().positive().max(24 * 60 * 60_000).optional(),
479
+ allowlistedThreads: z.array(z.string().min(1)).max(1000).optional(),
480
+ }).strict().optional(),
436
481
  localVm: localVmConfigSchema.optional(),
437
482
  features: featureConfigSchema.optional(),
438
483
  onboarding: onboardingConfigSchema.optional(),
@@ -541,8 +586,9 @@ export function roomTurnTimeoutMinutes(cfg) {
541
586
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
542
587
  }
543
588
  /** Room handoff tree budgets in milliseconds. The tree lifetime pauses
544
- * while a node is actively executing; the hard cap is wall-clock and bounds
545
- * trees that never stop. Read when the server starts. */
589
+ * while a node is actively executing; the hard cap is the maximum stall
590
+ * window, measured wall-clock from the tree's last durable progress. Read
591
+ * when the server starts. */
546
592
  export function roomHandoffLimits(cfg) {
547
593
  return {
548
594
  lifetimeMs: (cfg.rooms?.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) * 60_000,
@@ -578,9 +624,26 @@ export function localVmMaxInstances(cfg) {
578
624
  export function skillAuthoringEnabled(cfg) {
579
625
  return cfg.features?.skillAuthoring !== false;
580
626
  }
627
+ /** Automatic recall is read-only, so it is on unless switched off. */
628
+ export function autoRecallEnabled(cfg) {
629
+ return cfg.features?.autoRecall !== false;
630
+ }
631
+ export const DEFAULT_CAPTURE_QUIET_MS = 2 * 60_000;
632
+ export const DEFAULT_TIDY_HOUR = 3;
633
+ export function captureQuietMs(cfg) {
634
+ return cfg.memory?.captureQuietMs ?? DEFAULT_CAPTURE_QUIET_MS;
635
+ }
636
+ export function tidyHour(cfg) {
637
+ return cfg.memory?.tidyHour ?? DEFAULT_TIDY_HOUR;
638
+ }
581
639
  export function showToolCallsEnabled(cfg) {
582
640
  return cfg.features?.showToolCalls === true;
583
641
  }
642
+ /** A routine's turns are posted in the conversation that receives its card.
643
+ * Off by default, so scheduled work stays in a hidden thread. */
644
+ export function routinesInConversationEnabled(cfg) {
645
+ return cfg.features?.routinesInConversation === true;
646
+ }
584
647
  /** Workspace-level gate for the experimental built-in browser. A bot's own
585
648
  * switch sits under it, so either can withhold the browser. */
586
649
  export function builtInBrowserEnabled(cfg) {
@@ -614,6 +677,43 @@ export function claudeUserMcpEnabled(cfg) {
614
677
  export function llmThreadTitlesEnabled(cfg) {
615
678
  return cfg.features?.llmThreadTitles === true;
616
679
  }
680
+ /** Idle release for computer claims (#1653): a whole-turn desktop hold
681
+ * ends after a screen-quiet window (default 90 seconds, screen-poller
682
+ * frames excluded) instead of at turn settle, and the previous holder
683
+ * re-claims directly inside a reclaim window (default 10 minutes) while
684
+ * yielding to a seat another turn already holds. Off unless an explicit
685
+ * `true` — bake it as a maintainer-only flag first, exactly like
686
+ * sharedComputers: enable by hand in ~/.openmausbot/config.json
687
+ * (`{"features": {"computerClaimIdleRelease": true}}`) and restart. */
688
+ export function computerClaimIdleReleaseEnabled(cfg) {
689
+ return cfg.features?.computerClaimIdleRelease === true;
690
+ }
691
+ /** Consented cloud overflow for local computer waits (#1655): while a turn
692
+ * waits for a local desktop, it may offer overflow to a per-second-billed
693
+ * cloud seat — never silently: the card names the cost, and only an
694
+ * explicit per-conversation consent or a configured allowlist thread may
695
+ * start the machine, which then hard-stops after an idle window. Off
696
+ * unless an explicit `true`, like computerClaimIdleRelease: enable by
697
+ * hand in ~/.openmausbot/config.json
698
+ * (`{"features": {"cloudOverflow": true}, "cloudOverflow": {"perSecondCostUsd": 0.0004}}`)
699
+ * and restart. */
700
+ export function cloudOverflowEnabled(cfg) {
701
+ return cfg.features?.cloudOverflow === true;
702
+ }
703
+ /** The per-second rate shown on the card, or null when the operator has
704
+ * not set one: the offer fails closed rather than guess a price. */
705
+ export function cloudOverflowPerSecondCostUsd(cfg) {
706
+ const cost = cfg.cloudOverflow?.perSecondCostUsd;
707
+ return typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null;
708
+ }
709
+ /** How long a cloud seat may stay screen-quiet before it is stopped. */
710
+ export function cloudOverflowIdleStopMs(cfg) {
711
+ return cfg.cloudOverflow?.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
712
+ }
713
+ /** Threads with standing consent, by exact thread id. */
714
+ export function cloudOverflowAllowlistedThreads(cfg) {
715
+ return new Set(cfg.cloudOverflow?.allowlistedThreads ?? []);
716
+ }
617
717
  /** Config sections no provider driver reads. A write that touches only
618
718
  * these must not rebuild the fleet: rebuilding disposes every engine child
619
719
  * and reloads it, seconds of work that would also interrupt in-flight
@@ -629,8 +729,10 @@ export const FLEET_NEUTRAL_KEYS = new Set([
629
729
  "threads",
630
730
  "automaticRecovery",
631
731
  "context",
732
+ "memory",
632
733
  "localVm",
633
734
  "features",
735
+ "cloudOverflow",
634
736
  "browserProfiles",
635
737
  "onboarding",
636
738
  ]);
@@ -752,6 +854,7 @@ export function loadConfig() {
752
854
  cfg.composio = { ...cfg.composio };
753
855
  if (process.env.COMPOSIO_API_KEY !== undefined)
754
856
  cfg.composio.apiKey = process.env.COMPOSIO_API_KEY;
857
+ // BOX_TOKEN keeps its historical name; the provider is Boat.
755
858
  cfg.box = { ...cfg.box };
756
859
  if (process.env.BOX_TOKEN !== undefined)
757
860
  cfg.box.token = process.env.BOX_TOKEN;
@@ -761,6 +864,11 @@ export function loadConfig() {
761
864
  cfg.tts = { ...cfg.tts };
762
865
  if (process.env.OMB_TTS_KEY !== undefined)
763
866
  cfg.tts.key = process.env.OMB_TTS_KEY;
867
+ // A preset ElevenLabs voice (Cloud Pro sets one) is only a default: a voice or
868
+ // another speech provider the person picked in Settings always wins.
869
+ const presetVoice = process.env.OMB_TTS_DEFAULT_VOICE?.trim();
870
+ if (presetVoice && !cfg.tts.voice?.trim() && (cfg.tts.provider ?? "elevenlabs") === "elevenlabs")
871
+ cfg.tts.voice = presetVoice;
764
872
  if (process.env.OMB_FISH_AUDIO_API_KEY !== undefined)
765
873
  cfg.tts.fishKey = process.env.OMB_FISH_AUDIO_API_KEY;
766
874
  cfg.imageGen = { ...cfg.imageGen };
@@ -929,7 +1037,7 @@ export function saveConfig(patch, options = {}) {
929
1037
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
930
1038
  if (storedProfiles.success)
931
1039
  disk.browserProfiles = storedProfiles.data;
932
- for (const key of ["xai", "anthropic", "mistral", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "context", "localVm", "features", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1040
+ for (const key of ["xai", "anthropic", "mistral", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
933
1041
  const section = checkedPatch[key];
934
1042
  if (!section)
935
1043
  continue;
@@ -1138,6 +1246,7 @@ function injectedEnvironment(cfg, driver) {
1138
1246
  environment.set("OPENAI_COMPAT_API_KEY", cfg.openaiCompat.key);
1139
1247
  if (driver === "openai-compat" && cfg.openaiCompat?.url)
1140
1248
  environment.set("OPENAI_COMPAT_URL", cfg.openaiCompat.url);
1249
+ // driverKind "boxAgent" and env BOX_TOKEN keep their historical names.
1141
1250
  if (driver === "boxAgent" && cfg.box?.token)
1142
1251
  environment.set("BOX_TOKEN", cfg.box.token);
1143
1252
  if (driver === "opencodeGo" && cfg.opencodeGo?.apiKey)