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
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { BOT_AVATAR_CROPS, botAvatarCropSchema, botAvatarUrlSchema } from "../shared/bot-avatar.js";
2
+ import { AVATAR_ZOOM_MAX, AVATAR_ZOOM_MIN, BOT_AVATAR_CROPS, botAvatarCropSchema, botAvatarUrlSchema, } from "../shared/bot-avatar.js";
3
3
  import { BOT_PROFILE_LIMITS, fitsOnOneLine } from "../shared/bot-profile.js";
4
4
  import { MASCOT_BODY_IDS, mascotBodySchema } from "../shared/mascot-bodies.js";
5
5
  // Shared with the package format, which checks the same rule on names it
@@ -13,6 +13,9 @@ export const BOT_PROFILE_PATCH_FIELDS = [
13
13
  "notifications",
14
14
  "avatarUrl",
15
15
  "avatarCrop",
16
+ "avatarZoom",
17
+ "avatarFocusX",
18
+ "avatarFocusY",
16
19
  "mascotBody",
17
20
  "voice",
18
21
  "speakReplies",
@@ -46,6 +49,9 @@ export const profilePatchSchema = z.object({
46
49
  })
47
50
  .optional(),
48
51
  avatarCrop: botAvatarCropSchema.optional(),
52
+ avatarZoom: z.number({ error: "avatarZoom must be a number from 1 to 3" }).finite().min(AVATAR_ZOOM_MIN).max(AVATAR_ZOOM_MAX).optional(),
53
+ avatarFocusX: z.number({ error: "avatarFocusX must be a number from 0 to 1" }).finite().min(0).max(1).optional(),
54
+ avatarFocusY: z.number({ error: "avatarFocusY must be a number from 0 to 1" }).finite().min(0).max(1).optional(),
49
55
  mascotBody: mascotBodySchema.optional(),
50
56
  voice: z
51
57
  .string({ error: "voice must be a string" })
@@ -7,6 +7,7 @@
7
7
  // never saw.
8
8
  import { newId } from "./contracts.js";
9
9
  import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
10
+ import { drainCoalesceHead, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
10
11
  const queues = new Map(); // threadId -> waiting sends
11
12
  export function restoreChannelMessages() {
12
13
  queues.clear();
@@ -16,7 +17,14 @@ export function restoreChannelMessages() {
16
17
  const entry = queues.get(row.threadId) ?? { groupId: row.ownerId, items: [] };
17
18
  if (entry.groupId !== row.ownerId)
18
19
  throw new Error("queued task belongs to another channel");
19
- entry.items.push({ ...row.payload, id: row.id, mode: row.payload.mode ?? "chat" });
20
+ entry.items.push({
21
+ ...row.payload,
22
+ id: row.id,
23
+ mode: row.payload.mode ?? "chat",
24
+ // rows queued before timestamps were kept read as queued at restore
25
+ // time: the burst was still live when the restart interrupted it
26
+ queuedAt: row.payload.queuedAt ?? Date.now(),
27
+ });
20
28
  queues.set(row.threadId, entry);
21
29
  }
22
30
  }
@@ -33,6 +41,7 @@ export function queueChannelMessage(groupId, threadId, text, options = {}) {
33
41
  via: options.via,
34
42
  sender: options.sender,
35
43
  trigger: options.trigger,
44
+ queuedAt: Date.now(),
36
45
  };
37
46
  saveChatFollowup({ id: item.id, kind: "channel", ownerId: groupId, threadId, payload: item });
38
47
  entry.items.push(item);
@@ -66,10 +75,11 @@ export function cancelChannelMessage(groupId, queueId) {
66
75
  /** Atomically lift a channel thread's whole queue out for a live steer. The
67
76
  * entry leaves first so a room that settles while the adapter is still
68
77
  * thinking can never also drain the same words as a follow-up turn. Only
69
- * the HEAD can be lifted: the success path steers and settles items[0], so
70
- * a request naming a later item must not lift the queue at all (it would
71
- * steer and delete a different message's words). The caller must either
72
- * restore the held queue or settle its head. */
78
+ * the HEAD can be lifted (the chip the UI offers Steer on): the success
79
+ * path steers and settles the head GROUP — the leading coalesced run — as
80
+ * one, so a request naming a later item must not lift the queue at all (it
81
+ * would steer and delete words the requester did not name). The caller must
82
+ * either restore the held queue or settle its head group. */
73
83
  export function holdChannelQueue(groupId, threadId, queueId) {
74
84
  const entry = queues.get(threadId);
75
85
  if (!entry || entry.groupId !== groupId || entry.items[0]?.id !== queueId)
@@ -106,11 +116,13 @@ export function resolveHeldReplyTarget(held, resolve) {
106
116
  }
107
117
  }
108
118
  /** Mark a held queue's head delivered — its words were folded into the
109
- * running turn — and re-queue the rest for the room's normal one-at-a-time
110
- * drain. A restart must not replay the steered head as a fresh follow-up. */
119
+ * running turn — the whole head group steers as one — and re-queue the
120
+ * rest for the room's one-coalesced-item-per-turn drain. A restart must
121
+ * not replay the steered words as a fresh follow-up. */
111
122
  export function settleHeldChannelQueueHead(held) {
112
- const [head, ...rest] = held.items;
113
- settleChatFollowups([head.id], null);
123
+ const head = headChannelGroup(held.items);
124
+ const rest = held.items.slice(head.length);
125
+ settleChatFollowups(head.map((item) => item.id), null);
114
126
  if (rest.length === 0)
115
127
  return;
116
128
  const existing = queues.get(held.threadId);
@@ -122,27 +134,55 @@ export function settleHeldChannelQueueHead(held) {
122
134
  });
123
135
  }
124
136
  /**
125
- * Start at most one follow-up per idle channel. Starting it synchronously
126
- * marks the channel working again; its completion calls this drain for the
127
- * next item. Removing first makes repeated settle notifications harmless.
137
+ * Start at most one follow-up per idle channel: the leading coalesced
138
+ * group — one sender's contiguous in-window burst — runs as ONE item.
139
+ * Starting it synchronously marks the channel working again; its completion
140
+ * calls this drain for the next group. Removing first makes repeated settle
141
+ * notifications harmless.
128
142
  */
129
143
  export function drainChannelMessages(isWorking, run) {
130
144
  for (const [threadId, entry] of queues) {
131
145
  if (isWorking(entry.groupId))
132
146
  continue;
133
- const item = entry.items[0];
134
- if (!item) {
147
+ if (entry.items.length === 0) {
135
148
  queues.delete(threadId);
136
149
  continue;
137
150
  }
138
- settleChatFollowups([item.id], "dispatching");
139
- entry.items.shift();
151
+ const group = headChannelGroup(entry.items);
152
+ const ids = group.map((item) => item.id);
153
+ settleChatFollowups(ids, "dispatching");
154
+ entry.items.splice(0, group.length);
140
155
  if (entry.items.length === 0)
141
156
  queues.delete(threadId);
142
- const running = run({ ...item, groupId: entry.groupId, threadId });
143
- void Promise.resolve(running).then(() => settleChatFollowups([item.id], null), () => settleChatFollowups([item.id], "interrupted")).catch((error) => console.warn("channel-queue: could not settle durable follow-up", error));
157
+ const running = run({ groupId: entry.groupId, threadId, items: group });
158
+ void Promise.resolve(running).then(() => settleChatFollowups(ids, null), () => settleChatFollowups(ids, "interrupted")).catch((error) => console.warn("channel-queue: could not settle durable follow-up", error));
144
159
  }
145
160
  }
161
+ /** The leading run of queued room messages that drain as one item: same
162
+ * sender, same provenance shape, each within the coalescing window of the
163
+ * one before it, bounded at the room-context window — a room turn reads
164
+ * the burst through that window, so a longer run would append lines the
165
+ * responder never sees; the excess stays queued for the next turn. Manual
166
+ * head-steer folds and settles exactly this group into the live turn. */
167
+ export function headChannelGroup(items) {
168
+ return drainCoalesceHead(items, coalesceIdentity, (item) => item.queuedAt, DRAIN_COALESCE_MAX_ITEMS);
169
+ }
170
+ /** A queued room message's coalescing identity: WHO sent it, with the
171
+ * provenance shape as part of the identity. A person's texts merge only
172
+ * with that same person's; an API send has no person behind it, so it never
173
+ * merges with anything, and neither does a goal (its coordinator routing
174
+ * is per-send). Unattributed local sends (the loopback owner typing in the
175
+ * room composer) are one identity — the transcript already names them all
176
+ * the same. */
177
+ function coalesceIdentity(item) {
178
+ if (item.via === "api")
179
+ return `api:${item.id}`;
180
+ if (item.mode !== "chat")
181
+ return `mode:${item.mode}:${item.id}`;
182
+ if (item.sender)
183
+ return `person:${item.sender.id ?? item.sender.name}`;
184
+ return "person:local";
185
+ }
146
186
  /** Test helper. */
147
187
  export function _queuedChannelCount(threadId) {
148
188
  return queues.get(threadId)?.items.length ?? 0;
@@ -10,8 +10,8 @@
10
10
  //
11
11
  // Restore is redo-friendly on purpose: it commits a safety point, then moves
12
12
  // the index and work tree back with `git restore --source` + `git clean -fd`
13
- // while HEAD stays put — so the state that was just replaced is itself a
14
- // checkpoint, and "undo the undo" is one more restore. Ignored/excluded files
13
+ // while HEAD keeps the safety point — so the state just replaced is the one
14
+ // retained checkpoint, and "undo the undo" is one more restore. Ignored/excluded files
15
15
  // (node_modules, .env, media) are never snapshotted and never removed by one.
16
16
  //
17
17
  // Failure policy: checkpointing is best-effort convenience, never load-bearing.
@@ -25,7 +25,7 @@
25
25
  // service, and the snapshot-before-every-turn cadence follows Cline.
26
26
  import { execFile } from "node:child_process";
27
27
  import { createHash } from "node:crypto";
28
- import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
28
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { isAbsolute, join, parse, resolve } from "node:path";
31
31
  import { DATA_DIR } from "./config.js";
@@ -119,8 +119,9 @@ Thumbs.db
119
119
  *.gguf
120
120
  `;
121
121
  // gpgsign off: the user's global config may demand signing, and a shadow
122
- // commit must never block on a passphrase prompt. gc off: background gc in
123
- // a repo we treat as disposable only risks lock contention with snapshots.
122
+ // commit must never block on a passphrase prompt. Automatic GC stays off:
123
+ // explicit GC runs inside the same serialization as snapshot/restore, after
124
+ // their source objects are no longer needed.
124
125
  const GITCONFIG = "[commit]\n\tgpgsign = false\n[core]\n\tautocrlf = false\n[gc]\n\tauto = 0\n";
125
126
  /** One failed git call disables checkpoints for that bot until restart —
126
127
  * a broken shadow repo must cost the user one log line, not a failed turn. */
@@ -261,6 +262,28 @@ async function ensureShadow(cwd, env, shadow, signal) {
261
262
  }
262
263
  mkdirSync(join(shadow, ".git", "info"), { recursive: true });
263
264
  writeFileSync(join(shadow, ".git", "info", "exclude"), EXCLUDES);
265
+ // Cargo and other tools can put caches outside conventional target/ or
266
+ // build/ paths. Honor the standard tag even for previously indexed caches.
267
+ const tags = (await runGit(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "**/CACHEDIR.TAG"], cwd, env, signal)).split("\0");
268
+ const cachePatterns = new Set();
269
+ for (const tag of tags) {
270
+ if (!tag.includes("/") || /[\r\n]/.test(tag))
271
+ continue;
272
+ try {
273
+ const path = join(cwd, tag);
274
+ const stat = lstatSync(path);
275
+ if (!stat.isFile() || stat.size > 4096)
276
+ continue;
277
+ if (!readFileSync(path, "utf8").startsWith("Signature: 8a477f597d28d172789f06886806bc55"))
278
+ continue;
279
+ const directory = tag.slice(0, -"CACHEDIR.TAG".length);
280
+ cachePatterns.add("/" + directory.replace(/[\\*?[\]#! ]/g, "\\$&"));
281
+ }
282
+ catch {
283
+ // A disappearing/unreadable tag is not evidence that a path is a cache.
284
+ }
285
+ }
286
+ writeFileSync(join(shadow, ".git", "info", "exclude"), EXCLUDES + [...cachePatterns].join("\n") + "\n");
264
287
  try {
265
288
  await runGit(["rev-parse", "--verify", "HEAD"], cwd, env, signal);
266
289
  }
@@ -269,23 +292,43 @@ async function ensureShadow(cwd, env, shadow, signal) {
269
292
  await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal);
270
293
  }
271
294
  }
272
- /** Stage everything and commit if anything actually changed. `complete`
273
- * records whether every path was indexable: ordinary snapshots may keep the
274
- * useful subset, but restore must not delete files its safety point missed. */
295
+ /** Capture fully before advancing HEAD. A partial add must never replace
296
+ * the last usable checkpoint or authorize removal of its objects. */
275
297
  async function commitAll(cwd, env, label, signal) {
276
- // --ignore-errors skips files git cannot index (unreadable, FIFOs) instead
277
- // of aborting — but still exits 1 when it skipped an unreadable file, so
278
- // the exit code is retained even though a partial snapshot remains useful.
279
- // Restore treats an incomplete safety point as a hard stop before touching
280
- // the work tree.
281
- let complete = true;
282
- await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal).catch(() => {
283
- complete = false;
284
- });
285
- if (await hasStagedChanges(cwd, env, signal)) {
286
- await runGit(["commit", "--no-verify", "-m", label], cwd, env, signal);
298
+ const previous = (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim();
299
+ try {
300
+ // Rebuild only the shadow index so newly ignored/cache-tagged files stop
301
+ // pinning old blobs; no user index or work-tree file is changed.
302
+ await runGit(["read-tree", "--empty"], cwd, env, signal);
303
+ await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
304
+ }
305
+ catch {
306
+ return { hash: previous, complete: false };
307
+ }
308
+ const changed = await hasStagedChanges(cwd, env, signal);
309
+ const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], cwd, env, signal)).trim();
310
+ const count = (await runGit(["rev-list", "--count", "HEAD"], cwd, env, signal)).trim();
311
+ if (!changed && Number(count) <= 2)
312
+ return { hash: previous, complete: true };
313
+ const tree = (await runGit(["write-tree"], cwd, env, signal)).trim();
314
+ const message = changed ? label : (await runGit(["show", "-s", "--format=%B", "HEAD"], cwd, env, signal)).trim();
315
+ // Parent only the empty marker, never the previous snapshot: keeping the
316
+ // previous commit as an ancestor would retain every old tree indefinitely.
317
+ const hash = (await runGit(["commit-tree", tree, "-p", base, "-m", message], cwd, env, signal)).trim();
318
+ await runGit(["update-ref", "HEAD", hash, previous], cwd, env, signal);
319
+ return { hash, complete: true };
320
+ }
321
+ async function collectObsolete(cwd, env) {
322
+ // No caller may still need the previous tree. In particular, restore runs
323
+ // this only AFTER using its source; the safety checkpoint stays reachable.
324
+ try {
325
+ await runGit(["reflog", "expire", "--expire=now", "--all"], cwd, env);
326
+ await runGit(["gc", "--prune=now", "--quiet"], cwd, env);
327
+ }
328
+ catch {
329
+ // A busy/full disk must not hide an otherwise usable restore point.
330
+ console.warn("checkpoint cleanup deferred; the latest restore point is preserved");
287
331
  }
288
- return { hash: (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim(), complete };
289
332
  }
290
333
  /** Snapshot the folder. Returns the checkpoint hash, or null when the
291
334
  * feature is off for this bot, git is missing, or the folder is refused.
@@ -305,7 +348,11 @@ export async function snapshot(botId, cwd, label, signal) {
305
348
  return await serialize(shadow, async () => {
306
349
  const env = gitEnv(shadow, worktree);
307
350
  await ensureShadow(worktree, env, shadow, signal);
308
- return (await commitAll(worktree, env, label, signal)).hash;
351
+ const result = await commitAll(worktree, env, label, signal);
352
+ if (!result.complete)
353
+ return null;
354
+ await collectObsolete(worktree, env);
355
+ return result.hash;
309
356
  });
310
357
  }
311
358
  catch (e) {
@@ -314,45 +361,42 @@ export async function snapshot(botId, cwd, label, signal) {
314
361
  return null;
315
362
  }
316
363
  }
317
- /** Paths that differ between two checkpoints of the same bot+folder, from
318
- * `git diff --name-status` against the shadow repo. Null when the hashes
319
- * are equal, the folder is refused or checkpoints are off, or git fails —
320
- * the digest then simply omits its file list. Never throws (turn path). */
321
- export async function diffStat(botId, cwd, fromHash, toHash, signal) {
322
- if (signal?.aborted)
364
+ /** Diff the settled workspace without replacing its pre-turn restore point.
365
+ * Staging is confined to the shadow index; the user's files and Git are untouched. */
366
+ export async function diffWorkingTree(botId, cwd, fromHash, signal) {
367
+ if (signal?.aborted || !COMMIT_HASH.test(fromHash))
323
368
  return null;
324
- if (fromHash === toHash)
325
- return null;
326
- if (disabledBots.has(botId))
327
- return null;
328
- if (!(await gitAvailable()))
329
- return null;
330
- if (refusalReason(cwd) !== null)
369
+ if (disabledBots.has(botId) || !(await gitAvailable()) || refusalReason(cwd) !== null)
331
370
  return null;
332
371
  try {
333
372
  const worktree = realpathSync(resolve(cwd));
334
373
  const shadow = shadowDir(botId, worktree);
335
374
  const env = gitEnv(shadow, worktree);
336
- const out = await serialize(shadow, () => runGit(["diff", "--name-status", "--no-renames", "-z", fromHash, toHash], worktree, env, signal));
337
- const diff = { changed: [], added: [], deleted: [] };
338
- const fields = out.split("\0").filter((field) => field.length > 0);
339
- for (let i = 0; i + 1 < fields.length; i += 2) {
340
- const status = fields[i];
341
- const path = fields[i + 1];
342
- if (status.startsWith("A"))
343
- diff.added.push(path);
344
- else if (status.startsWith("D"))
345
- diff.deleted.push(path);
346
- else
347
- diff.changed.push(path);
348
- }
349
- return diff;
375
+ return await serialize(shadow, async () => {
376
+ await ensureShadow(worktree, env, shadow, signal);
377
+ await runGit(["read-tree", "--empty"], worktree, env, signal);
378
+ await runGit(["add", "-A", "--ignore-errors", "."], worktree, env, signal);
379
+ const out = await runGit(["diff", "--cached", "--name-status", "--no-renames", "-z", fromHash], worktree, env, signal);
380
+ const diff = { changed: [], added: [], deleted: [] };
381
+ const fields = out.split("\0").filter((field) => field.length > 0);
382
+ for (let i = 0; i + 1 < fields.length; i += 2) {
383
+ const status = fields[i];
384
+ const path = fields[i + 1];
385
+ if (status.startsWith("A"))
386
+ diff.added.push(path);
387
+ else if (status.startsWith("D"))
388
+ diff.deleted.push(path);
389
+ else
390
+ diff.changed.push(path);
391
+ }
392
+ return diff;
393
+ });
350
394
  }
351
395
  catch {
352
396
  return null;
353
397
  }
354
398
  }
355
- /** Every checkpoint for this bot+folder, newest first. Empty when nothing
399
+ /** The latest usable checkpoint (plus legacy history until the next snapshot). Empty when nothing
356
400
  * was ever snapshotted (listing never creates the shadow repo). The empty
357
401
  * base marker is omitted — it is not a state anyone should return to. */
358
402
  export async function listCheckpoints(botId, cwd) {
@@ -422,6 +466,7 @@ export async function restore(botId, cwd, hash) {
422
466
  if (base === hash)
423
467
  return { ok: false, error: "that is the empty base marker, not a checkpoint" };
424
468
  // safety point: whatever is about to be overwritten becomes restorable
469
+ await ensureShadow(worktree, env, shadow);
425
470
  const safety = await commitAll(worktree, env, "before restore");
426
471
  if (!safety.complete) {
427
472
  return {
@@ -439,9 +484,10 @@ export async function restore(botId, cwd, hash) {
439
484
  // (unreadable files, add races) — never ignored/excluded files (no -x).
440
485
  await runGit(["restore", "--source", hash, "--staged", "--worktree", "--", "."], worktree, env);
441
486
  await runGit(["clean", "-fd"], worktree, env);
442
- // record the post-restore state (also re-syncs the index with the
443
- // deletions restore made), so the timeline shows the rollback
444
- await commitAll(worktree, env, `restored ${hash.slice(0, 8)}`);
487
+ // Retain the pre-restore safety point, not another copy of the state
488
+ // now on disk. Reset only the shadow index so it cannot pin old blobs.
489
+ await runGit(["read-tree", "HEAD"], worktree, env);
490
+ await collectObsolete(worktree, env);
445
491
  return { ok: true };
446
492
  });
447
493
  }
@@ -0,0 +1,33 @@
1
+ /** Quiet-window policy for releasing a held seat while its turn lives.
2
+ *
3
+ * The policy is deliberately synchronous and clock-injected: like the
4
+ * claim map it serves, expiry is evaluated when a claim is touched
5
+ * (claim, blocker, owns) rather than by a timer, so the server's single
6
+ * thread decides every transition and tests pin time exactly. Slice
7
+ * #1655 reuses this window math for its own idle release; keep this
8
+ * module free of server imports. */
9
+ /** Default quiet window: no screen activity for this long releases the seat. */
10
+ export const COMPUTER_CLAIM_QUIET_MS = 90_000;
11
+ /** Default reclaim window: the previous holder re-claims directly for this long. */
12
+ export const COMPUTER_CLAIM_RECLAIM_MS = 10 * 60_000;
13
+ export class IdleReleasePolicy {
14
+ quietMs;
15
+ reclaimMs;
16
+ constructor(quietMs = COMPUTER_CLAIM_QUIET_MS, reclaimMs = COMPUTER_CLAIM_RECLAIM_MS) {
17
+ if (!Number.isFinite(quietMs) || quietMs <= 0)
18
+ throw new Error("Idle release quiet window must be positive");
19
+ if (!Number.isFinite(reclaimMs) || reclaimMs <= 0)
20
+ throw new Error("Idle release reclaim window must be positive");
21
+ this.quietMs = quietMs;
22
+ this.reclaimMs = reclaimMs;
23
+ }
24
+ /** True once the seat has been quiet for the full window. Screen-poller
25
+ * frames never refresh the activity clock, so they cannot keep a seat. */
26
+ quietElapsed(lastActivityAt, now) {
27
+ return now - lastActivityAt >= this.quietMs;
28
+ }
29
+ /** True while a released seat's previous holder still re-claims directly. */
30
+ reclaimLive(releasedAt, now) {
31
+ return now < releasedAt + this.reclaimMs;
32
+ }
33
+ }
@@ -1,6 +1,6 @@
1
1
  export const CLOUD_BACKEND_CHANGE_ERROR = "stop the active turn before changing the cloud backend";
2
2
  export const VPS_ALIAS_CHANGE_ERROR = "stop the active VPS turn before changing the SSH config alias";
3
- export const BOX_ACCOUNT_RESOURCES_ERROR = "remove this installation's cloud computers before changing or clearing the Box account";
3
+ export const BOAT_ACCOUNT_RESOURCES_ERROR = "remove this installation's cloud computers before changing or clearing the Boat account";
4
4
  export const VPS_ALIAS_RESOURCES_ERROR = "remove this installation's VPS computers before changing or clearing the SSH config alias";
5
5
  export function cloudBackendChangeError(botBusy, activeVpsThread) {
6
6
  return botBusy || activeVpsThread ? CLOUD_BACKEND_CHANGE_ERROR : null;
@@ -8,14 +8,14 @@ export function cloudBackendChangeError(botBusy, activeVpsThread) {
8
8
  export function vpsAliasChangeError(currentAlias, nextAlias, activeVpsThread) {
9
9
  return activeVpsThread && currentAlias !== nextAlias ? VPS_ALIAS_CHANGE_ERROR : null;
10
10
  }
11
- /** An old Box account may be detached only when it owns no local resources.
11
+ /** An old Boat account may be detached only when it owns no local resources.
12
12
  * Token rotation is safe when the replacement credential proves access to
13
13
  * the exact same provider identities. */
14
- export function boxAccountResourceChangeError(current, replacement) {
14
+ export function boatAccountResourceChangeError(current, replacement) {
15
15
  if (current.length === 0)
16
16
  return null;
17
17
  if (!replacement || replacement.length !== current.length)
18
- return BOX_ACCOUNT_RESOURCES_ERROR;
18
+ return BOAT_ACCOUNT_RESOURCES_ERROR;
19
19
  const identities = (rows) => rows
20
20
  .map(({ boxId, name }) => `${boxId}\u0000${name}`)
21
21
  .sort();
@@ -23,7 +23,7 @@ export function boxAccountResourceChangeError(current, replacement) {
23
23
  const replacementIdentities = identities(replacement);
24
24
  return currentIdentities.every((identity, index) => identity === replacementIdentities[index])
25
25
  ? null
26
- : BOX_ACCOUNT_RESOURCES_ERROR;
26
+ : BOAT_ACCOUNT_RESOURCES_ERROR;
27
27
  }
28
28
  export function vpsAliasResourceChangeError(instanceCount) {
29
29
  return instanceCount > 0 ? VPS_ALIAS_RESOURCES_ERROR : null;
@@ -0,0 +1,103 @@
1
+ // Entry point of the OMB Cloud Pro home image (deploy/fly/Dockerfile).
2
+ //
3
+ // Starts as root only to hand a fresh Fly volume (mounted root-owned at
4
+ // /data) to the unprivileged `maus` user, then drops privileges for good
5
+ // and runs two children: the OpenMausBot server on 127.0.0.1:8799 (and its
6
+ // webhook receiver on :8800) and the Caddy edge on 0.0.0.0:8080. The edge is
7
+ // the only listener the network can reach, and it always forwards with
8
+ // X-Forwarded-*, so request-auth.ts never grants a remote request loopback
9
+ // trust. If either child exits, both stop and the machine restarts.
10
+ import { spawn } from "node:child_process";
11
+ import { chownSync, readFileSync, statSync } from "node:fs";
12
+ import { dirname, join, posix, resolve } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { cloudHomeConfiguration, cloudHomeHost, prepareCloudHomeVolume, withoutIgnoredCloudKeys } from "./cloud-home.js";
15
+ const SERVICE_USER = "maus";
16
+ /** uid/gid from /etc/passwd; Node has no getpwnam. */
17
+ export function passwdIds(passwd, name) {
18
+ for (const line of passwd.split("\n")) {
19
+ const [user, , uid, gid] = line.split(":");
20
+ if (user === name && /^\d+$/.test(uid ?? "") && /^\d+$/.test(gid ?? ""))
21
+ return { uid: Number(uid), gid: Number(gid) };
22
+ }
23
+ return null;
24
+ }
25
+ /** The server child's environment: the operator's contract plus fixed
26
+ * ports and paths (Linux paths inside the image, so POSIX joins on every
27
+ * host that builds them, tests included), never a platform gateway's settings. The edge child gets
28
+ * only what it needs to route. */
29
+ export function cloudHomeChildEnvironments(config, env, home) {
30
+ const server = {
31
+ ...withoutIgnoredCloudKeys(env), HOME: home, OMB_DATA_DIR: env.OMB_DATA_DIR || posix.join(home, ".openmausbot"),
32
+ OMB_PORT: "8799", OMB_WEBHOOK_PORT: "8800", OMB_PUBLIC_URL: config.publicOrigin,
33
+ OMB_WEBHOOK_PUBLIC_URL: env.OMB_WEBHOOK_PUBLIC_URL || config.publicOrigin,
34
+ };
35
+ const edge = {
36
+ PATH: env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: "/tmp/omb-edge",
37
+ XDG_DATA_HOME: "/tmp/omb-edge/data", XDG_CONFIG_HOME: "/tmp/omb-edge/config",
38
+ OMB_CLOUD_PUBLIC_HOST: cloudHomeHost(config),
39
+ };
40
+ return { server, edge };
41
+ }
42
+ export function startCloudHome(env = process.env) {
43
+ process.umask(0o077);
44
+ const config = cloudHomeConfiguration(env);
45
+ if (!config)
46
+ throw new Error("This image runs an OMB Cloud home machine; set its boot contract (docs/cloud-pro.md).");
47
+ // Logged here once: the server child never sees what they are about.
48
+ for (const warning of config.warnings)
49
+ console.warn(`cloud home: ${warning}`);
50
+ const home = env.HOME || "/data";
51
+ if (process.getuid?.() === 0) {
52
+ const ids = passwdIds(readFileSync("/etc/passwd", "utf8"), SERVICE_USER);
53
+ if (!ids)
54
+ throw new Error(`The ${SERVICE_USER} user is missing from this image.`);
55
+ // A new volume is a root-owned mount point. Only the mount point itself
56
+ // changes owner; anything inside keeps the owner it already has.
57
+ const stat = statSync(home);
58
+ if (stat.uid !== ids.uid || stat.gid !== ids.gid)
59
+ chownSync(home, ids.uid, ids.gid);
60
+ process.setgroups?.([]);
61
+ process.setgid(ids.gid);
62
+ process.setuid(ids.uid);
63
+ }
64
+ if (process.getuid?.() === 0)
65
+ throw new Error("The Cloud home must run as an unprivileged user.");
66
+ prepareCloudHomeVolume(home, config.machineId);
67
+ const here = dirname(fileURLToPath(import.meta.url));
68
+ const { server, edge } = cloudHomeChildEnvironments(config, env, home);
69
+ const children = [];
70
+ let stopping = false;
71
+ const stop = (failed) => {
72
+ if (stopping)
73
+ return;
74
+ stopping = true;
75
+ process.exitCode = failed ? 1 : 0;
76
+ for (const child of children)
77
+ if (child.exitCode === null)
78
+ child.kill("SIGTERM");
79
+ const force = setTimeout(() => { for (const child of children)
80
+ if (child.exitCode === null)
81
+ child.kill("SIGKILL"); }, 20_000);
82
+ force.unref();
83
+ };
84
+ const run = (command, args, childEnv) => {
85
+ const child = spawn(command, args, { env: childEnv, stdio: "inherit" });
86
+ children.push(child);
87
+ child.once("error", () => stop(true));
88
+ child.once("exit", () => stop(true));
89
+ };
90
+ run(process.execPath, [join(here, "index.js")], server);
91
+ run(env.OMB_CLOUD_EDGE_BIN || "/usr/local/bin/caddy", ["run", "--config", env.OMB_CLOUD_EDGE_CONFIG || "/app/cloud/Caddyfile", "--adapter", "caddyfile"], edge);
92
+ process.once("SIGTERM", () => stop(false));
93
+ process.once("SIGINT", () => stop(false));
94
+ }
95
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
96
+ try {
97
+ startCloudHome();
98
+ }
99
+ catch (error) {
100
+ console.error(`Cloud home startup failed: ${error instanceof Error ? error.message : String(error)}`);
101
+ process.exitCode = 1;
102
+ }
103
+ }