openmausbot 0.1.90 → 0.1.91

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 (112) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-yP-w8pRq.js} +1 -1
  2. package/dist/assets/{arc-q73qM-At.js → arc-DsivfZEh.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-CILw7lzT.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-DEQUV3HF.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-lSXCZoKA.js} +1 -1
  6. package/dist/assets/channel-DrfGMu7X.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-QCD5bgWP.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-C5-iZJuL.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-cxsawMpY.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-DV8tV8H7.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-CJFZyfbW.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-BigbMXco.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-oVxTfpDy.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CH4BiroW.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-KEDa6gax.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-BiPjfqVv.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-Caxew3OY.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-B_Pui369.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-X64ydIhP.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-TqYCI1E1.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-TeOt3rAQ.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-D70BLhDA.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-BbQDLwUC.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-ChNJZ1u-.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-DMBl2APa.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-CEqDchOp.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BVDKRp_-.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-CRkofO9B.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-DruOMh5e.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-8GMQeB7t.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-ORE9jcsM.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BmsedsWh.js} +1 -1
  33. package/dist/assets/index-BlnCEcU6.js +588 -0
  34. package/dist/assets/{index-G1PHKGyh.js → index-DKNEM8Q6.js} +1 -1
  35. package/dist/assets/index-DVxjOvID.css +1 -0
  36. package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-E_p7AooJ.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-Y-B2mKda.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-eA8RELcK.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-DqIL2e8e.js} +1 -1
  40. package/dist/assets/{linear-Dwc3Hrru.js → linear-yC_29R6e.js} +1 -1
  41. package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-VhEMkSND.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-oJ7kyQZy.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-BmfYcY0O.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-DKvgP7w9.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-BEKL3W0L.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BEsiqR5s.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-DWBcWvAP.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-rpZ2_O4s.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-B99HQdJR.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BjN0qT5T.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-BbsVgGsC.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-DG3gKkeR.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BTHOdtF_.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-acLSLFwc.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C5JzNX9G.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-DL1-PrR_.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.js → xychartDiagram-PMCCYNJV-Bvr1477p.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/cloud-home-start.js +18 -3
  61. package/dist-server/container-mcp.js +84 -32
  62. package/dist-server/drivers/agents-proxy.js +11 -0
  63. package/dist-server/index.js +25183 -23580
  64. package/dist-server/openmausbot.js +313 -159
  65. package/dist-server/pair-cli.js +313 -159
  66. package/dist-server/server/boat.js +66 -21
  67. package/dist-server/server/bot-overview.js +7 -4
  68. package/dist-server/server/cloud-home-start.js +23 -5
  69. package/dist-server/server/cloud-home.js +25 -2
  70. package/dist-server/server/cloud-lending.js +103 -0
  71. package/dist-server/server/cloud-move-http.js +268 -0
  72. package/dist-server/server/cloud-move.js +418 -0
  73. package/dist-server/server/config.js +42 -4
  74. package/dist-server/server/decider/index.js +238 -0
  75. package/dist-server/server/decider/jev.js +183 -0
  76. package/dist-server/server/decider/log.js +62 -0
  77. package/dist-server/server/decider/relay.js +52 -0
  78. package/dist-server/server/decider/room-routing.js +88 -0
  79. package/dist-server/server/decider/types.js +5 -0
  80. package/dist-server/server/drivers/agents-catalog.js +16 -0
  81. package/dist-server/server/drivers/boatagent.js +20 -12
  82. package/dist-server/server/drivers/claude.js +1 -0
  83. package/dist-server/server/group-goal-run.js +6 -4
  84. package/dist-server/server/harness-capabilities.js +2 -0
  85. package/dist-server/server/included-services.js +70 -0
  86. package/dist-server/server/index.js +438 -102
  87. package/dist-server/server/model-context-window.js +4 -4
  88. package/dist-server/server/package-export.js +17 -10
  89. package/dist-server/server/package-import.js +6 -3
  90. package/dist-server/server/package-parts.js +3 -1
  91. package/dist-server/server/request-auth.js +2 -0
  92. package/dist-server/server/routes/decider.js +19 -0
  93. package/dist-server/server/shared-computers.js +116 -30
  94. package/dist-server/server/store.js +17 -2
  95. package/dist-server/server/surface.js +13 -0
  96. package/dist-server/server/system-prompt.js +11 -0
  97. package/dist-server/server/team-backup.js +6 -2
  98. package/dist-server/server/team-manifest.js +11 -3
  99. package/dist-server/server/tts/elevenlabs.js +11 -6
  100. package/dist-server/server/tts/index.js +17 -10
  101. package/dist-server/server/workspace-backup-policy.js +22 -3
  102. package/dist-server/server/workspace-backup.js +25 -8
  103. package/dist-server/shared/cloud-home.js +4 -0
  104. package/dist-server/shared/package-format.js +30 -4
  105. package/dist-server/shared/team-backup.js +20 -3
  106. package/dist-server/vps-container-mcp.js +84 -32
  107. package/dist-server/workspace-backup.worker.js +113 -31
  108. package/package.json +1 -1
  109. package/dist/assets/channel-BCuyHjFV.js +0 -1
  110. package/dist/assets/index-CRvo-DS2.css +0 -1
  111. package/dist/assets/index-CWYGlA63.js +0 -588
  112. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +0 -8
@@ -0,0 +1,418 @@
1
+ // Move to Cloud (docs/cloud-pro.md): a person's one-time copy of their local
2
+ // workspace onto their OMB Cloud home. The desktop exports the ordinary
3
+ // encrypted workspace backup (workspace-backup.ts) and uploads it here in
4
+ // parts; this machine then previews and restores it like any other backup,
5
+ // so everything that backup policy keeps out (credentials, sign-ins, pairing
6
+ // and sessions, runtime state) stays where it is on both sides.
7
+ //
8
+ // What this file adds: the workspace's size and contents, a resumable upload
9
+ // slot, the space check for the machine's fixed-size volume, a backup of the
10
+ // Cloud's own workspace taken before it is replaced ("Restore previous Cloud"
11
+ // swaps it back), and the tidying that keeps that backup the only copy left
12
+ // behind. Everything lives under `.backups`, which no snapshot includes and
13
+ // no restore replaces.
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statfsSync, writeFileSync } from "node:fs";
16
+ import { open } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { DatabaseSync } from "node:sqlite";
19
+ import { writeFileAtomic } from "./atomic.js";
20
+ import { MAX_WORKSPACE_BACKUP_FILES, MAX_WORKSPACE_BACKUP_UPLOAD_BYTES, omittedFromWorkspaceBackup, removeWorkspaceBackupJob, stageWorkspaceBackup, } from "./workspace-backup.js";
21
+ /** The part size the desktop sends; a part may be at most CLOUD_MOVE_MAX_PART_BYTES. */
22
+ export const CLOUD_MOVE_PART_BYTES = 16 * 1024 ** 2;
23
+ export const CLOUD_MOVE_MAX_PART_BYTES = 64 * 1024 ** 2;
24
+ export const CLOUD_MOVE_MAX_BYTES = MAX_WORKSPACE_BACKUP_UPLOAD_BYTES;
25
+ export const CLOUD_MOVE_MAX_FILES = MAX_WORKSPACE_BACKUP_FILES;
26
+ /** Headroom kept free on the volume after a move's own files. */
27
+ export const CLOUD_MOVE_SPACE_MARGIN = 256 * 1024 ** 2;
28
+ // Encrypted header plus tag: anything smaller cannot be a backup.
29
+ const MIN_BYTES = 60;
30
+ const SHA256 = /^[a-f0-9]{64}$/;
31
+ // An upload nobody has touched for a day is abandoned, not resumable.
32
+ const UPLOAD_STALE_MS = 24 * 3600_000;
33
+ const fail = (message, status, extra = {}) => Object.assign(new Error(message), { status, ...extra });
34
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
35
+ function rosterLength(path) {
36
+ try {
37
+ const value = JSON.parse(readFileSync(path, "utf8"));
38
+ return Array.isArray(value) ? value.length : 0;
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ }
44
+ /** What the person would call their work: bots, rooms, and the conversations
45
+ * they took part in (a starter bot's greeting is not a chat). */
46
+ export function workspaceContents(dataDir) {
47
+ let chats = 0;
48
+ const database = join(dataDir, "messages.db");
49
+ if (existsSync(database)) {
50
+ const db = new DatabaseSync(database, { timeout: 5_000 });
51
+ try {
52
+ const columns = db.prepare("SELECT name FROM pragma_table_info('messages')").all().map((row) => String(row.name));
53
+ if (columns.length) {
54
+ const who = columns.includes("role") ? "role" : "json_extract(json, '$.role')";
55
+ chats = Number(db.prepare(`SELECT COUNT(DISTINCT thread_id) AS chats FROM messages WHERE ${who} = 'user'`).get()?.chats ?? 0);
56
+ }
57
+ }
58
+ finally {
59
+ db.close();
60
+ }
61
+ }
62
+ return { bots: rosterLength(join(dataDir, "bots.json")), rooms: rosterLength(join(dataDir, "groups.json")), chats };
63
+ }
64
+ /** A fresh machine: its one starter bot at most, no rooms, nobody has chatted. */
65
+ export function isEmptyWorkspace(contents) {
66
+ return contents.bots <= 1 && contents.rooms === 0 && contents.chats === 0;
67
+ }
68
+ /** Bytes and files a backup of this workspace would carry, by the export's
69
+ * own rules. An estimate: live files can change before the export. */
70
+ export function workspaceMoveSize(dataDir) {
71
+ let bytes = 0, files = 0;
72
+ const walk = (directory, prefix) => {
73
+ for (const name of readdirSync(directory)) {
74
+ const path = prefix ? `${prefix}/${name}` : name;
75
+ if (omittedFromWorkspaceBackup(path))
76
+ continue;
77
+ const stat = lstatSync(join(directory, name));
78
+ if (stat.isSymbolicLink())
79
+ continue;
80
+ if (stat.isDirectory())
81
+ walk(join(directory, name), path);
82
+ else if (stat.isFile()) {
83
+ bytes += stat.size;
84
+ files++;
85
+ }
86
+ if (files > CLOUD_MOVE_MAX_FILES)
87
+ return;
88
+ }
89
+ };
90
+ if (existsSync(dataDir))
91
+ walk(dataDir, "");
92
+ return { bytes, files };
93
+ }
94
+ export function freeVolumeBytes(path) {
95
+ const disk = statfsSync(path, { bigint: true });
96
+ const free = disk.bavail * disk.bsize;
97
+ return free > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(free);
98
+ }
99
+ /** Peak extra space a move of `upload` bytes needs: the upload, its decrypted
100
+ * copy and its staged files exist together while it is checked; the restore
101
+ * then installs from the staged copy. Replacing a workspace first backs it up
102
+ * (a snapshot and its archive, briefly both). */
103
+ export function moveSpaceNeeded(upload, current, replacing) {
104
+ return 3 * upload + (replacing ? 2 * current : 0) + CLOUD_MOVE_SPACE_MARGIN;
105
+ }
106
+ function privateFolder(path) {
107
+ mkdirSync(path, { recursive: true, mode: 0o700 });
108
+ const stat = lstatSync(path);
109
+ if (!stat.isDirectory() || stat.isSymbolicLink())
110
+ throw fail("Backup storage must be a real folder, not a link.", 500);
111
+ return path;
112
+ }
113
+ const backupsRoot = (dataDir) => privateFolder(join(dataDir, ".backups"));
114
+ // Reads never create the folder; beginUpload does.
115
+ const uploadFolder = (dataDir) => join(dataDir, ".backups", "cloud-move");
116
+ const uploadMetaPath = (dataDir) => join(uploadFolder(dataDir), "upload.json");
117
+ const uploadPartPath = (dataDir) => join(uploadFolder(dataDir), "upload.part");
118
+ function readUploadMeta(dataDir) {
119
+ try {
120
+ const value = JSON.parse(readFileSync(uploadMetaPath(dataDir), "utf8"));
121
+ if (record(value) && typeof value.sha256 === "string" && SHA256.test(value.sha256) && Number.isSafeInteger(value.bytes) && Number.isSafeInteger(value.updatedAt)) {
122
+ return value;
123
+ }
124
+ }
125
+ catch { /* none, or unreadable: a fresh upload replaces it */ }
126
+ return null;
127
+ }
128
+ function receivedBytes(dataDir) {
129
+ try {
130
+ const stat = lstatSync(uploadPartPath(dataDir));
131
+ return stat.isFile() ? stat.size : 0;
132
+ }
133
+ catch {
134
+ return 0;
135
+ }
136
+ }
137
+ export function uploadStatus(dataDir) {
138
+ const meta = readUploadMeta(dataDir);
139
+ return meta ? { sha256: meta.sha256, bytes: meta.bytes, received: Math.min(receivedBytes(dataDir), meta.bytes) } : null;
140
+ }
141
+ export function discardUpload(dataDir) {
142
+ rmSync(uploadPartPath(dataDir), { force: true });
143
+ rmSync(uploadMetaPath(dataDir), { force: true });
144
+ }
145
+ /** Check a declared upload: its size, file count and SHA-256. */
146
+ export function validUploadDeclaration(input) {
147
+ if (typeof input.sha256 !== "string" || !SHA256.test(input.sha256))
148
+ throw fail("This is not a workspace backup.", 400);
149
+ if (!Number.isSafeInteger(input.bytes) || input.bytes < MIN_BYTES)
150
+ throw fail("This is not a workspace backup.", 400);
151
+ if (input.bytes > CLOUD_MOVE_MAX_BYTES)
152
+ throw fail("This workspace is larger than the 10 GB a move can carry.", 413);
153
+ if (input.files !== undefined && (!Number.isSafeInteger(input.files) || input.files < 0 || input.files > CLOUD_MOVE_MAX_FILES)) {
154
+ throw fail("This workspace has more than the 100,000 files a move can carry.", 413);
155
+ }
156
+ return { sha256: input.sha256, bytes: input.bytes };
157
+ }
158
+ /** Start an upload, or continue the same file's upload where it stopped. */
159
+ export function beginUpload(dataDir, declared, now = Date.now()) {
160
+ const existing = readUploadMeta(dataDir);
161
+ if (existing && existing.sha256 === declared.sha256 && existing.bytes === declared.bytes && existing.updatedAt + UPLOAD_STALE_MS > now && receivedBytes(dataDir) <= declared.bytes) {
162
+ writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...existing, updatedAt: now }), { mode: 0o600 });
163
+ return { ...declared, received: receivedBytes(dataDir) };
164
+ }
165
+ discardUpload(dataDir);
166
+ backupsRoot(dataDir);
167
+ privateFolder(uploadFolder(dataDir));
168
+ writeFileSync(uploadPartPath(dataDir), "", { mode: 0o600, flag: "wx" });
169
+ writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...declared, updatedAt: now }), { mode: 0o600 });
170
+ return { ...declared, received: 0 };
171
+ }
172
+ /** Store one part at `offset`. A part already stored is accepted again
173
+ * without writing; any other offset than the next one answers where the
174
+ * upload stands (409, `received`). A failed part is cut back off. */
175
+ export async function writeUploadPart(dataDir, sha256, offset, length, body, now = Date.now()) {
176
+ const meta = readUploadMeta(dataDir);
177
+ if (!meta || meta.sha256 !== sha256)
178
+ throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
179
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length <= 0 || length > CLOUD_MOVE_MAX_PART_BYTES) {
180
+ throw fail("Invalid upload part.", 400);
181
+ }
182
+ if (offset + length > meta.bytes)
183
+ throw fail("The upload is larger than the file it declared.", 413);
184
+ const received = receivedBytes(dataDir);
185
+ const drain = async () => { for await (const _chunk of body) { /* already stored */ } };
186
+ if (offset + length <= received) {
187
+ await drain();
188
+ return received;
189
+ }
190
+ if (offset !== received) {
191
+ await drain();
192
+ throw fail("The upload continues from another point.", 409, { received });
193
+ }
194
+ const handle = await open(uploadPartPath(dataDir), "r+");
195
+ let written = 0;
196
+ try {
197
+ for await (const chunk of body) {
198
+ if (written + chunk.length > length)
199
+ throw fail("The upload part is larger than it declared.", 413);
200
+ let done = 0;
201
+ while (done < chunk.length)
202
+ done += (await handle.write(chunk, done, chunk.length - done, offset + written + done)).bytesWritten;
203
+ written += chunk.length;
204
+ }
205
+ if (written !== length)
206
+ throw fail("The upload part was incomplete.", 400);
207
+ await handle.sync();
208
+ }
209
+ catch (error) {
210
+ await handle.truncate(received).catch(() => { });
211
+ throw error;
212
+ }
213
+ finally {
214
+ await handle.close();
215
+ }
216
+ writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...meta, updatedAt: now }), { mode: 0o600 });
217
+ return received + length;
218
+ }
219
+ /** The complete upload's path, once its size and SHA-256 match what was declared. */
220
+ export async function completedUpload(dataDir, sha256) {
221
+ const status = uploadStatus(dataDir);
222
+ if (!status || status.sha256 !== sha256)
223
+ throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
224
+ if (status.received !== status.bytes)
225
+ throw fail("The upload is not complete yet.", 409, { received: status.received });
226
+ const hash = createHash("sha256");
227
+ for await (const chunk of createReadStream(uploadPartPath(dataDir), { highWaterMark: 1024 * 1024 }))
228
+ hash.update(chunk);
229
+ if (hash.digest("hex") !== sha256) {
230
+ discardUpload(dataDir);
231
+ throw fail("The uploaded file was damaged on the way. Start the move again.", 400);
232
+ }
233
+ return uploadPartPath(dataDir);
234
+ }
235
+ const previousFolder = (dataDir) => join(dataDir, ".backups", "cloud-previous");
236
+ const nextPreviousFolder = (dataDir) => join(dataDir, ".backups", "cloud-previous.next");
237
+ const moveRestoresPath = (dataDir) => join(dataDir, ".backups", "cloud-move-restores.json");
238
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
239
+ function readPrevious(folder) {
240
+ try {
241
+ const value = JSON.parse(readFileSync(join(folder, "previous.json"), "utf8"));
242
+ if (!record(value) || typeof value.password !== "string" || typeof value.createdAt !== "string" || !record(value.contents))
243
+ return null;
244
+ if (!lstatSync(join(folder, "workspace.ombbackup")).isFile())
245
+ return null;
246
+ return value;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
252
+ /** The previous Cloud that can be put back, never its password. A fresh
253
+ * Cloud's (its starter bot, nothing else) is kept but not offered. */
254
+ export function previousCloud(dataDir) {
255
+ const previous = readPrevious(previousFolder(dataDir));
256
+ if (!previous || isEmptyWorkspace(previous.contents))
257
+ return null;
258
+ return { createdAt: previous.createdAt, ...previous.contents, bytes: lstatSync(join(previousFolder(dataDir), "workspace.ombbackup")).size };
259
+ }
260
+ /** Back up this workspace as the next previous Cloud, for the restore
261
+ * `restoreId`. `create` is createWorkspaceBackup, run by the caller inside
262
+ * the maintenance gate. It becomes the previous Cloud only once startup has
263
+ * installed that restore. */
264
+ export async function prepareNextPreviousCloud(dataDir, restoreId, create) {
265
+ const password = randomBytes(32).toString("base64url");
266
+ const contents = workspaceContents(dataDir);
267
+ const created = await create(password);
268
+ const next = nextPreviousFolder(dataDir);
269
+ try {
270
+ backupsRoot(dataDir);
271
+ rmSync(next, { recursive: true, force: true });
272
+ privateFolder(next);
273
+ renameSync(created.path, join(next, "workspace.ombbackup"));
274
+ const meta = { password, createdAt: new Date().toISOString(), contents, forRestore: restoreId };
275
+ writeFileAtomic(join(next, "previous.json"), JSON.stringify(meta), { mode: 0o600 });
276
+ return { createdAt: meta.createdAt, ...contents };
277
+ }
278
+ catch (error) {
279
+ rmSync(next, { recursive: true, force: true });
280
+ throw error;
281
+ }
282
+ finally {
283
+ try {
284
+ removeWorkspaceBackupJob(dataDir, created.id);
285
+ }
286
+ catch { /* The export job expires with the others. */ }
287
+ }
288
+ }
289
+ /** Drop a next previous Cloud whose restore did not commit. */
290
+ export function discardNextPreviousCloud(dataDir) {
291
+ rmSync(nextPreviousFolder(dataDir), { recursive: true, force: true });
292
+ }
293
+ /** Stage the previous Cloud for an ordinary restore; its archive stays. */
294
+ export async function stagePreviousCloud(dataDir, appVersion) {
295
+ const previous = readPrevious(previousFolder(dataDir));
296
+ if (!previous || isEmptyWorkspace(previous.contents))
297
+ throw fail("There is no previous Cloud to restore.", 404);
298
+ const archive = join(previousFolder(dataDir), "workspace.ombbackup");
299
+ const staged = await stageWorkspaceBackup(dataDir, archive, { password: previous.password, currentAppVersion: appVersion });
300
+ return { ...staged, bytes: lstatSync(archive).size };
301
+ }
302
+ /** The archive size of the previous Cloud (0 without one), for space checks. */
303
+ export function previousCloudArchiveBytes(dataDir) {
304
+ try {
305
+ return lstatSync(join(previousFolder(dataDir), "workspace.ombbackup")).size;
306
+ }
307
+ catch {
308
+ return 0;
309
+ }
310
+ }
311
+ function readMoveRestores(dataDir) {
312
+ try {
313
+ const value = JSON.parse(readFileSync(moveRestoresPath(dataDir), "utf8"));
314
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string" && UUID.test(id)) : [];
315
+ }
316
+ catch {
317
+ return [];
318
+ }
319
+ }
320
+ function writeMoveRestores(dataDir, ids) {
321
+ backupsRoot(dataDir);
322
+ if (ids.length)
323
+ writeFileAtomic(moveRestoresPath(dataDir), JSON.stringify([...new Set(ids)]), { mode: 0o600 });
324
+ else
325
+ rmSync(moveRestoresPath(dataDir), { force: true });
326
+ }
327
+ /** Remember a restore made by a move or a swap back, before it commits, so
328
+ * its safety copy and staged files are tidied once it is installed. */
329
+ export function noteMoveRestore(dataDir, restoreId) {
330
+ writeMoveRestores(dataDir, [...readMoveRestores(dataDir), restoreId]);
331
+ }
332
+ export function forgetMoveRestore(dataDir, restoreId) {
333
+ writeMoveRestores(dataDir, readMoveRestores(dataDir).filter((id) => id !== restoreId));
334
+ }
335
+ /** Delete a staged job, and its safety copy, by id: guarded paths only. */
336
+ export function removeMoveFiles(dataDir, id) {
337
+ if (!UUID.test(id))
338
+ return;
339
+ for (const name of [id, `safety-${id}`]) {
340
+ const path = join(dataDir, ".backups", name);
341
+ try {
342
+ const stat = lstatSync(path);
343
+ if (stat.isDirectory() && !stat.isSymbolicLink())
344
+ rmSync(path, { recursive: true, force: true });
345
+ }
346
+ catch { /* already gone */ }
347
+ }
348
+ }
349
+ function pendingRestoreId(dataDir) {
350
+ try {
351
+ const value = JSON.parse(readFileSync(join(dataDir, ".backups", "pending-restore.json"), "utf8"));
352
+ return record(value) && typeof value.id === "string" ? value.id : null;
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ }
358
+ /** At a Cloud home's startup, after any pending restore was applied:
359
+ * - a next previous Cloud becomes the previous Cloud once its restore is
360
+ * installed, and is dropped otherwise;
361
+ * - moves' and swaps' restores, once settled, keep no safety copy or staged
362
+ * files (the previous Cloud is the one undo point);
363
+ * - an upload nobody has touched for a day is deleted.
364
+ * Never touches a restore still waiting to be applied. */
365
+ export function tidyCloudMoveStorage(dataDir, restored, now = Date.now()) {
366
+ if (!existsSync(join(dataDir, ".backups")))
367
+ return;
368
+ const pending = pendingRestoreId(dataDir);
369
+ const next = readPrevious(nextPreviousFolder(dataDir));
370
+ if (next && next.forRestore !== pending) {
371
+ if (restored.restored && restored.id === next.forRestore) {
372
+ rmSync(previousFolder(dataDir), { recursive: true, force: true });
373
+ renameSync(nextPreviousFolder(dataDir), previousFolder(dataDir));
374
+ }
375
+ else
376
+ discardNextPreviousCloud(dataDir);
377
+ }
378
+ else if (!next && existsSync(nextPreviousFolder(dataDir)) && !pending)
379
+ discardNextPreviousCloud(dataDir);
380
+ const settled = readMoveRestores(dataDir).filter((id) => id !== pending);
381
+ for (const id of settled)
382
+ removeMoveFiles(dataDir, id);
383
+ writeMoveRestores(dataDir, readMoveRestores(dataDir).filter((id) => id === pending));
384
+ if (restored.id && settled.includes(restored.id)) {
385
+ // The receipt must not point at a safety copy that is gone.
386
+ delete restored.safetyCopyPath;
387
+ try {
388
+ const receipt = join(dataDir, ".backups", "last-restore.json");
389
+ const value = JSON.parse(readFileSync(receipt, "utf8"));
390
+ if (record(value) && value.id === restored.id && "safetyCopyPath" in value) {
391
+ delete value.safetyCopyPath;
392
+ writeFileAtomic(receipt, JSON.stringify(value), { mode: 0o600 });
393
+ }
394
+ }
395
+ catch { /* no receipt */ }
396
+ }
397
+ const upload = readUploadMeta(dataDir);
398
+ if ((upload && upload.updatedAt + UPLOAD_STALE_MS <= now) || (!upload && existsSync(uploadPartPath(dataDir))))
399
+ discardUpload(dataDir);
400
+ }
401
+ /** Bytes under `.backups`: what backups, safety copies and moves hold on the volume. */
402
+ export function backupsBytes(dataDir) {
403
+ let bytes = 0;
404
+ const walk = (directory) => {
405
+ for (const name of readdirSync(directory)) {
406
+ const stat = lstatSync(join(directory, name));
407
+ if (stat.isDirectory() && !stat.isSymbolicLink())
408
+ walk(join(directory, name));
409
+ else if (stat.isFile())
410
+ bytes += stat.size;
411
+ }
412
+ };
413
+ try {
414
+ walk(join(dataDir, ".backups"));
415
+ }
416
+ catch { /* none yet */ }
417
+ return bytes;
418
+ }
@@ -14,6 +14,7 @@ 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";
17
18
  const optionalText = z.string().optional();
18
19
  const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
19
20
  const LEGACY_BROWSER_PROFILE_ID = /^[A-Za-z0-9_-]{1,40}$/;
@@ -429,6 +430,24 @@ const appConfigSchema = z.object({
429
430
  .optional(),
430
431
  model: optionalText,
431
432
  }).optional(),
433
+ /** The decision model (server/decider): a fast classifier that picks
434
+ * things for bots, starting with who answers a room message. `key` is
435
+ * write-only like every credential; `baseUrl` points at a Jev-compatible
436
+ * server instead of TypeSafe's (an operator setting with no UI); `jobs`
437
+ * switches each decision on or off. Not `decisions`: that section is the
438
+ * authorization log's retention. */
439
+ decider: z.object({
440
+ enabled: z.boolean().optional(),
441
+ provider: z.enum(["jev", "off"]).optional(),
442
+ key: optionalText,
443
+ baseUrl: z
444
+ .string()
445
+ .trim()
446
+ .max(2048)
447
+ .refine((value) => !value || /^https?:\/\//i.test(value), "the decision model address must start with http:// or https://")
448
+ .optional(),
449
+ jobs: z.object({ roomRouting: z.boolean().optional() }).optional(),
450
+ }).optional(),
432
451
  /** Avatar provider credentials stay separate; choosing a router never reuses a cloud key. */
433
452
  imageGen: z.object({
434
453
  provider: z.enum(["openai", "xai", "custom"]).optional(),
@@ -645,9 +664,13 @@ export function routinesInConversationEnabled(cfg) {
645
664
  return cfg.features?.routinesInConversation === true;
646
665
  }
647
666
  /** Workspace-level gate for the experimental built-in browser. A bot's own
648
- * switch sits under it, so either can withhold the browser. */
649
- export function builtInBrowserEnabled(cfg) {
650
- return cfg.features?.browser === true;
667
+ * switch sits under it, so either can withhold the browser. Off until the
668
+ * desktop's first-run welcome turns it on. A Cloud home (cloud-home.ts)
669
+ * skips that welcome, ships the browser in its image and has no other screen
670
+ * of its own, so there it is on unless the person switched it off. */
671
+ export function builtInBrowserEnabled(cfg, env = process.env) {
672
+ const chosen = cfg.features?.browser;
673
+ return chosen === undefined ? cloudHomeConfigured(env) : chosen === true;
651
674
  }
652
675
  /** Opt-in computer sharing: the routes, the agent tools, the advertised
653
676
  * capability and the desktop connector. Off unless an explicit `true` turns
@@ -723,6 +746,8 @@ export const FLEET_NEUTRAL_KEYS = new Set([
723
746
  "profile",
724
747
  "language",
725
748
  "tts",
749
+ // no engine reads it: the harness asks it before a turn starts
750
+ "decider",
726
751
  "imageGen",
727
752
  "vps",
728
753
  "rooms",
@@ -871,6 +896,9 @@ export function loadConfig() {
871
896
  cfg.tts.voice = presetVoice;
872
897
  if (process.env.OMB_FISH_AUDIO_API_KEY !== undefined)
873
898
  cfg.tts.fishKey = process.env.OMB_FISH_AUDIO_API_KEY;
899
+ cfg.decider = { ...cfg.decider };
900
+ if (process.env.OMB_JEV_API_KEY !== undefined)
901
+ cfg.decider.key = process.env.OMB_JEV_API_KEY;
874
902
  cfg.imageGen = { ...cfg.imageGen };
875
903
  if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
876
904
  cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
@@ -906,6 +934,7 @@ export function syncCredentialEnv(patch) {
906
934
  [patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
907
935
  [patch.tts?.key, "OMB_TTS_KEY"],
908
936
  [patch.tts?.fishKey, "OMB_FISH_AUDIO_API_KEY"],
937
+ [patch.decider?.key, "OMB_JEV_API_KEY"],
909
938
  [patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
910
939
  [patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
911
940
  ];
@@ -952,10 +981,17 @@ export const WORKSPACE_CREDENTIAL_ENV = [
952
981
  "OPENCODE_API_KEY",
953
982
  "OMB_TTS_KEY",
954
983
  "OMB_FISH_AUDIO_API_KEY",
984
+ "OMB_JEV_API_KEY",
955
985
  "OMB_OPENAI_IMAGE_KEY",
956
986
  "OMB_CUSTOM_IMAGE_KEY",
957
987
  "COMPOSIO_API_KEY",
958
988
  "OMB_COMPOSIO_BROKER_TOKEN",
989
+ // Cloud Pro's included Boat, voice and decision relay tokens
990
+ // (included-services.ts), used only in-process by the Boat, voice and
991
+ // decider modules.
992
+ "OMB_CLOUD_BOAT_TOKEN",
993
+ "OMB_CLOUD_VOICE_TOKEN",
994
+ "OMB_CLOUD_DECIDER_TOKEN",
959
995
  // Harness-private filesystem hints are not credentials themselves, but
960
996
  // exposing them to a shell-capable agent points straight at app-owned
961
997
  // state. The built-in browser master is delivered privately in memory.
@@ -1037,7 +1073,7 @@ export function saveConfig(patch, options = {}) {
1037
1073
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
1038
1074
  if (storedProfiles.success)
1039
1075
  disk.browserProfiles = storedProfiles.data;
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"]) {
1076
+ for (const key of ["xai", "anthropic", "mistral", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1041
1077
  const section = checkedPatch[key];
1042
1078
  if (!section)
1043
1079
  continue;
@@ -1247,6 +1283,8 @@ function injectedEnvironment(cfg, driver) {
1247
1283
  if (driver === "openai-compat" && cfg.openaiCompat?.url)
1248
1284
  environment.set("OPENAI_COMPAT_URL", cfg.openaiCompat.url);
1249
1285
  // driverKind "boxAgent" and env BOX_TOKEN keep their historical names.
1286
+ // Only the person's own token: without one the driver itself falls back
1287
+ // to Cloud Pro's included token, which never enters an environment map.
1250
1288
  if (driver === "boxAgent" && cfg.box?.token)
1251
1289
  environment.set("BOX_TOKEN", cfg.box.token);
1252
1290
  if (driver === "opencodeGo" && cfg.opencodeGo?.apiKey)