openmausbot 0.1.89 → 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 (168) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-yP-w8pRq.js} +1 -1
  2. package/dist/assets/{arc-Be-8GKxd.js → arc-DsivfZEh.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-CILw7lzT.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DEQUV3HF.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-lSXCZoKA.js} +1 -1
  6. package/dist/assets/channel-DrfGMu7X.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-QCD5bgWP.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-C5-iZJuL.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-cxsawMpY.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DV8tV8H7.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-CJFZyfbW.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BigbMXco.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-oVxTfpDy.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-CH4BiroW.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-KEDa6gax.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-BiPjfqVv.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-Caxew3OY.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-B_Pui369.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-X64ydIhP.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-TqYCI1E1.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-TeOt3rAQ.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-D70BLhDA.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BbQDLwUC.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-ChNJZ1u-.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-DMBl2APa.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-CEqDchOp.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-BVDKRp_-.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-CRkofO9B.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-DruOMh5e.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-8GMQeB7t.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-ORE9jcsM.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-BmsedsWh.js} +1 -1
  33. package/dist/assets/index-BlnCEcU6.js +588 -0
  34. package/dist/assets/{index-Oxk9x45i.js → index-DKNEM8Q6.js} +1 -1
  35. package/dist/assets/index-DVxjOvID.css +1 -0
  36. package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-E_p7AooJ.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-Y-B2mKda.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-eA8RELcK.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DqIL2e8e.js} +1 -1
  40. package/dist/assets/{linear-CtJnKe6A.js → linear-yC_29R6e.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-VhEMkSND.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-oJ7kyQZy.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-BmfYcY0O.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-DKvgP7w9.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-BEKL3W0L.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-BEsiqR5s.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-DWBcWvAP.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-rpZ2_O4s.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-B99HQdJR.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-BjN0qT5T.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-BbsVgGsC.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DG3gKkeR.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-BTHOdtF_.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-acLSLFwc.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-C5JzNX9G.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-DL1-PrR_.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-Bvr1477p.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/cloud-home-start.js +14810 -0
  61. package/dist-server/container-mcp.js +136 -36
  62. package/dist-server/drivers/agents-proxy.js +31 -10
  63. package/dist-server/index.js +48251 -44124
  64. package/dist-server/openmausbot.js +628 -292
  65. package/dist-server/pair-cli.js +628 -292
  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} +381 -334
  71. package/dist-server/server/bot-overview.js +7 -4
  72. package/dist-server/server/bot-profile.js +7 -1
  73. package/dist-server/server/channel-queue.js +58 -18
  74. package/dist-server/server/checkpoints.js +98 -52
  75. package/dist-server/server/claim-idle.js +33 -0
  76. package/dist-server/server/cloud-backend.js +5 -5
  77. package/dist-server/server/cloud-home-start.js +121 -0
  78. package/dist-server/server/cloud-home.js +241 -0
  79. package/dist-server/server/cloud-lending.js +103 -0
  80. package/dist-server/server/cloud-move-http.js +268 -0
  81. package/dist-server/server/cloud-move.js +418 -0
  82. package/dist-server/server/cloud-overflow.js +167 -0
  83. package/dist-server/server/composio.js +5 -0
  84. package/dist-server/server/computer-observation.js +2 -2
  85. package/dist-server/server/config.js +156 -9
  86. package/dist-server/server/container-computer.js +13 -0
  87. package/dist-server/server/control-client.js +2 -2
  88. package/dist-server/server/decider/index.js +238 -0
  89. package/dist-server/server/decider/jev.js +183 -0
  90. package/dist-server/server/decider/log.js +62 -0
  91. package/dist-server/server/decider/relay.js +52 -0
  92. package/dist-server/server/decider/room-routing.js +88 -0
  93. package/dist-server/server/decider/types.js +5 -0
  94. package/dist-server/server/delegations.js +11 -3
  95. package/dist-server/server/drivers/acp/core.js +5 -4
  96. package/dist-server/server/drivers/agents-call.js +5 -1
  97. package/dist-server/server/drivers/agents-catalog.js +25 -3
  98. package/dist-server/server/drivers/{boxagent.js → boatagent.js} +30 -21
  99. package/dist-server/server/drivers/builtIn.js +2 -2
  100. package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
  101. package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
  102. package/dist-server/server/drivers/claude.js +178 -13
  103. package/dist-server/server/drivers/native.js +1 -1
  104. package/dist-server/server/drivers/openai-chat.js +15 -4
  105. package/dist-server/server/drivers/pi.js +3 -3
  106. package/dist-server/server/group-goal-run.js +6 -4
  107. package/dist-server/server/harness-capabilities.js +2 -0
  108. package/dist-server/server/hosted-models.js +6 -1
  109. package/dist-server/server/included-services.js +70 -0
  110. package/dist-server/server/index.js +1462 -576
  111. package/dist-server/server/local-vm-inventory.js +11 -1
  112. package/dist-server/server/local-vm-seat-pool.js +141 -0
  113. package/dist-server/server/managed-policy.js +4 -1
  114. package/dist-server/server/memory-capture.js +194 -0
  115. package/dist-server/server/memory-entries.js +76 -0
  116. package/dist-server/server/memory-organize.js +102 -0
  117. package/dist-server/server/memory-tidy.js +144 -0
  118. package/dist-server/server/memory-topics.js +169 -0
  119. package/dist-server/server/memory-upkeep.js +452 -0
  120. package/dist-server/server/message-db.js +49 -5
  121. package/dist-server/server/model-context-window.js +4 -4
  122. package/dist-server/server/package-export.js +17 -10
  123. package/dist-server/server/package-import.js +6 -3
  124. package/dist-server/server/package-parts.js +3 -1
  125. package/dist-server/server/permission-proxy.js +3 -3
  126. package/dist-server/server/profile-learned.js +98 -0
  127. package/dist-server/server/profile-versions.js +45 -11
  128. package/dist-server/server/recall.js +172 -0
  129. package/dist-server/server/redact.js +1 -1
  130. package/dist-server/server/request-auth.js +2 -0
  131. package/dist-server/server/room-cwd.js +1 -1
  132. package/dist-server/server/room-handoffs.js +61 -6
  133. package/dist-server/server/routes/bot-memory.js +215 -0
  134. package/dist-server/server/routes/decider.js +19 -0
  135. package/dist-server/server/routine-requests.js +27 -16
  136. package/dist-server/server/routines.js +10 -6
  137. package/dist-server/server/shared-computers.js +116 -30
  138. package/dist-server/server/steer-queue.js +41 -10
  139. package/dist-server/server/store.js +42 -3
  140. package/dist-server/server/surface.js +14 -1
  141. package/dist-server/server/system-prompt.js +16 -5
  142. package/dist-server/server/team-backup.js +6 -2
  143. package/dist-server/server/team-computers.js +2 -2
  144. package/dist-server/server/team-manifest.js +11 -3
  145. package/dist-server/server/tightening-requests.js +34 -4
  146. package/dist-server/server/tool-summary.js +2 -2
  147. package/dist-server/server/tts/elevenlabs.js +12 -7
  148. package/dist-server/server/tts/index.js +17 -10
  149. package/dist-server/server/turn-resources.js +87 -11
  150. package/dist-server/server/vps-computer.js +1 -1
  151. package/dist-server/server/workspace-backup-policy.js +22 -3
  152. package/dist-server/server/workspace-backup.js +25 -8
  153. package/dist-server/server/workspace.js +57 -22
  154. package/dist-server/shared/ask-question.js +7 -5
  155. package/dist-server/shared/bot-avatar.js +19 -0
  156. package/dist-server/shared/cloud-home.js +4 -0
  157. package/dist-server/shared/credential-request.js +3 -3
  158. package/dist-server/shared/package-format.js +30 -4
  159. package/dist-server/shared/team-backup.js +20 -3
  160. package/dist-server/shared/tool-surface.js +1 -1
  161. package/dist-server/src/lib/composer-attachments.js +9 -0
  162. package/dist-server/vps-container-mcp.js +136 -36
  163. package/dist-server/workspace-backup.worker.js +165 -35
  164. package/package.json +1 -1
  165. package/dist/assets/channel-DqY_gMLg.js +0 -1
  166. package/dist/assets/index-BIMdIbaw.css +0 -1
  167. package/dist/assets/index-BVPdbq2r.js +0 -583
  168. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.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
+ }
@@ -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)