openmausbot 0.1.84 → 0.1.86

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 (230) hide show
  1. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  2. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  4. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  10. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  16. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  19. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  28. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  31. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  34. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  43. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  46. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  49. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  52. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  54. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  57. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  60. package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
  61. package/dist/assets/arc-DW7mrI4p.js +1 -0
  62. package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
  63. package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
  64. package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
  65. package/dist/assets/channel-BXFkgus9.js +1 -0
  66. package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
  67. package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
  68. package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
  69. package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
  70. package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
  71. package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
  72. package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
  73. package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
  74. package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
  75. package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
  76. package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
  77. package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
  78. package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
  79. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  80. package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
  81. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  82. package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
  83. package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
  84. package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
  85. package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
  86. package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
  87. package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
  88. package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
  89. package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
  90. package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
  91. package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
  92. package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
  93. package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
  94. package/dist/assets/graph-D8UmR1vK.js +1 -0
  95. package/dist/assets/index-BGgqlWPJ.css +1 -0
  96. package/dist/assets/index-BtHo8ZKl.js +577 -0
  97. package/dist/assets/{index-DNa2umw-.js → index-h9FFNwTu.js} +1 -1
  98. package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
  99. package/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
  101. package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
  102. package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
  103. package/dist/assets/linear-3su2TG93.js +1 -0
  104. package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
  105. package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
  106. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  107. package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
  108. package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
  109. package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
  110. package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
  111. package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
  112. package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
  113. package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
  114. package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
  115. package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
  116. package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
  117. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
  118. package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
  119. package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
  120. package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
  121. package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
  122. package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
  123. package/dist/index.html +2 -2
  124. package/dist-server/companion/src/routes.js +1 -0
  125. package/dist-server/container-mcp.js +231 -17
  126. package/dist-server/drivers/agents-proxy.js +16097 -1411
  127. package/dist-server/enterprise/LICENSE +32 -0
  128. package/dist-server/enterprise/server/index.js +6 -5
  129. package/dist-server/hooks/omb-hook.js +74 -0
  130. package/dist-server/index.js +12495 -7584
  131. package/dist-server/local-computer-proxy.js +83 -1
  132. package/dist-server/local-computer.js +14 -2
  133. package/dist-server/mcp-server.js +3 -1
  134. package/dist-server/openmausbot.js +5458 -4587
  135. package/dist-server/pair-cli.js +5458 -4587
  136. package/dist-server/proxy-paths.js +1 -0
  137. package/dist-server/server/admin-activity.js +410 -0
  138. package/dist-server/server/agent-tool-policy.js +1 -0
  139. package/dist-server/server/bot-package.js +2 -0
  140. package/dist-server/server/bot-visibility.js +462 -0
  141. package/dist-server/server/box.js +25 -5
  142. package/dist-server/server/browser-engine.js +87 -10
  143. package/dist-server/server/browser-live.js +7 -5
  144. package/dist-server/server/browser-runtime.js +66 -11
  145. package/dist-server/server/channel-queue.js +2 -0
  146. package/dist-server/server/checkpoints.js +62 -18
  147. package/dist-server/server/cli-prompts.js +26 -2
  148. package/dist-server/server/cli.js +79 -10
  149. package/dist-server/server/commands.js +27 -0
  150. package/dist-server/server/compaction-summary.js +78 -0
  151. package/dist-server/server/composio.js +49 -11
  152. package/dist-server/server/config.js +137 -14
  153. package/dist-server/server/context-budget.js +29 -0
  154. package/dist-server/server/context-rebuild.js +72 -0
  155. package/dist-server/server/decision-log.js +209 -48
  156. package/dist-server/server/default-model-selection.js +20 -3
  157. package/dist-server/server/delegations.js +11 -9
  158. package/dist-server/server/digest.js +130 -0
  159. package/dist-server/server/drivers/acp/core.js +637 -238
  160. package/dist-server/server/drivers/acp/grok.js +4 -3
  161. package/dist-server/server/drivers/agents-call.js +1123 -0
  162. package/dist-server/server/drivers/agents-catalog.js +659 -0
  163. package/dist-server/server/drivers/agents-client.js +28 -0
  164. package/dist-server/server/drivers/agents-proxy.js +18 -1683
  165. package/dist-server/server/drivers/agents-result.js +23 -0
  166. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  167. package/dist-server/server/drivers/claude.js +83 -16
  168. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  169. package/dist-server/server/drivers/codex.js +32 -4
  170. package/dist-server/server/drivers/grok.js +2 -1
  171. package/dist-server/server/drivers/openai-chat.js +66 -45
  172. package/dist-server/server/drivers/openai-compat.js +16 -8
  173. package/dist-server/server/enterprise.js +97 -16
  174. package/dist-server/server/external-runtime.js +83 -0
  175. package/dist-server/server/guarded-requests.js +60 -0
  176. package/dist-server/server/harness/http.js +61 -0
  177. package/dist-server/server/hooks/omb-hook.js +103 -0
  178. package/dist-server/server/hosted-models.js +151 -0
  179. package/dist-server/server/hosted-slack.js +11 -0
  180. package/dist-server/server/index.js +2810 -678
  181. package/dist-server/server/local-computer.js +21 -2
  182. package/dist-server/server/managed-desktop.js +79 -14
  183. package/dist-server/server/managed-policy.js +153 -0
  184. package/dist-server/server/mcp-bridge.js +10 -2
  185. package/dist-server/server/mcp-tool-images.js +37 -0
  186. package/dist-server/server/mcp-tool-schema.js +108 -0
  187. package/dist-server/server/message-db.js +108 -21
  188. package/dist-server/server/message-file.js +3 -2
  189. package/dist-server/server/model-context-window.js +7 -0
  190. package/dist-server/server/model-prices.js +121 -0
  191. package/dist-server/server/notify.js +10 -1
  192. package/dist-server/server/package-export.js +1 -0
  193. package/dist-server/server/peer-roster.js +17 -4
  194. package/dist-server/server/provider-icon.js +20 -0
  195. package/dist-server/server/proxy-paths.js +1 -0
  196. package/dist-server/server/recent-work.js +15 -0
  197. package/dist-server/server/request-auth.js +97 -0
  198. package/dist-server/server/room-handoffs.js +41 -13
  199. package/dist-server/server/routes/hosted-slack.js +20 -0
  200. package/dist-server/server/routes/table.js +14 -0
  201. package/dist-server/server/routine-requests.js +39 -1
  202. package/dist-server/server/routines.js +65 -9
  203. package/dist-server/server/shared-computer-control.js +31 -4
  204. package/dist-server/server/spend.js +111 -16
  205. package/dist-server/server/steer-queue.js +12 -1
  206. package/dist-server/server/store.js +384 -33
  207. package/dist-server/server/system-prompt.js +4 -4
  208. package/dist-server/server/team-backup.js +19 -2
  209. package/dist-server/server/thread-starters.js +53 -0
  210. package/dist-server/server/tool-results.js +72 -0
  211. package/dist-server/server/tts/grok.js +74 -0
  212. package/dist-server/server/tts/index.js +19 -1
  213. package/dist-server/server/usage-ledger.js +31 -8
  214. package/dist-server/server/webhooks.js +28 -0
  215. package/dist-server/server/workspace-backup-policy.js +9 -2
  216. package/dist-server/server/workspace-backup.js +7 -4
  217. package/dist-server/shared/approval-mode.js +7 -1
  218. package/dist-server/shared/digest.js +1 -0
  219. package/dist-server/shared/markdown-windows-paths.js +36 -0
  220. package/dist-server/shared/mention-boundary.js +15 -0
  221. package/dist-server/shared/provider-icon.js +110 -0
  222. package/dist-server/shared/routines.js +7 -1
  223. package/dist-server/shared/team-backup.js +5 -0
  224. package/dist-server/src/lib/composer-attachments.js +16 -0
  225. package/dist-server/vps-container-mcp.js +236 -22
  226. package/enterprise/README.md +33 -12
  227. package/enterprise/server/index.js +6 -5
  228. package/package.json +1 -1
  229. package/dist/assets/index-BbU5REzd.js +0 -310
  230. package/dist/assets/index-CLGfYlx_.css +0 -1
@@ -78,8 +78,15 @@ export class RoomHandoffs {
78
78
  trackExecutionPauses() {
79
79
  const now = this.now();
80
80
  const executing = new Set();
81
+ // Roots that still own unsettled nodes. A conversation stopped while a
82
+ // dispatched teammate executes leaves a terminal root above live work;
83
+ // pause accounting must survive that root until the whole tree settles.
84
+ const unsettled = new Set();
81
85
  for (const n of this.nodes.values()) {
82
- if (!terminal(n) && n.status === "running")
86
+ if (terminal(n))
87
+ continue;
88
+ unsettled.add(n.rootId);
89
+ if (n.status === "running")
83
90
  executing.add(n.rootId);
84
91
  }
85
92
  for (const rootId of executing) {
@@ -93,7 +100,7 @@ export class RoomHandoffs {
93
100
  pause.since = undefined;
94
101
  }
95
102
  const root = this.nodes.get(rootId);
96
- if (!root || terminal(root))
103
+ if (!root || !unsettled.has(rootId))
97
104
  this.pauses.delete(rootId);
98
105
  }
99
106
  }
@@ -236,12 +243,13 @@ export class RoomHandoffs {
236
243
  const node = this.nodes.get(generation);
237
244
  if (!node || node.status !== "source")
238
245
  return;
246
+ // An accepted assignment belongs to the queue, not the provider that
247
+ // submitted it. A failed/expired source turn must not erase that work.
248
+ // Explicit Stop, deletion and revoked routes still cancel separately.
239
249
  if (!ok)
240
- this.cancelTree(node, "The originating room turn did not finish", "failed");
241
- else {
242
- node.status = "waiting";
243
- this.publish(node);
244
- }
250
+ node.result = "The originating turn ended before its teammates returned.";
251
+ node.status = "waiting";
252
+ this.publish(node);
245
253
  }
246
254
  cancelTree(node, reason, status = "cancelled") {
247
255
  for (const child of this.children(node.id))
@@ -252,6 +260,8 @@ export class RoomHandoffs {
252
260
  node.result = reason;
253
261
  this.controllers.get(node.id)?.abort();
254
262
  }
263
+ // Settlement closes the paused span now, not at the next periodic tick.
264
+ this.trackExecutionPauses();
255
265
  this.publish(node);
256
266
  }
257
267
  cancelRoom(groupId, threadId) {
@@ -300,6 +310,7 @@ export class RoomHandoffs {
300
310
  node.status = "cancelled";
301
311
  node.result = reason;
302
312
  this.controllers.get(node.id)?.abort();
313
+ this.trackExecutionPauses();
303
314
  this.publish(node);
304
315
  }
305
316
  return left;
@@ -337,8 +348,10 @@ export class RoomHandoffs {
337
348
  }
338
349
  if (n.status !== "queued" && n.status !== "resume")
339
350
  continue;
340
- // A newly queued child starts only after its author has settled.
341
- if (parent && (parent.status === "source" || parent.status === "running"))
351
+ // Independent conversations can start as soon as work is accepted.
352
+ // Same-room speakers still serialize; never overlap their shared chat.
353
+ if (parent && (parent.status === "source" || parent.status === "running") &&
354
+ (parent.threadId === n.threadId || (n.groupId && n.groupId === parent.groupId)))
342
355
  continue;
343
356
  // A stopped source stops waiting; only work that never started is
344
357
  // dropped with it. A teammate mid-turn keeps its process and reports.
@@ -371,14 +384,29 @@ export class RoomHandoffs {
371
384
  if (terminal(n))
372
385
  return;
373
386
  n.result = result.text.slice(0, 12_000);
374
- if (!result.ok)
375
- this.cancelTree(n, n.result || "Room agent failed", "failed");
376
- else if (this.children(n.id).length > childCount)
387
+ if (this.children(n.id).length > childCount)
377
388
  n.status = "waiting";
389
+ else if (!result.ok)
390
+ this.cancelTree(n, n.result || "Room agent failed", "failed");
378
391
  else
379
392
  n.status = "completed";
393
+ // Close the paused span with the settlement itself: work enqueued
394
+ // before the next periodic tick must be admitted against the aged
395
+ // budget, not the still-open pause's overstated runway.
396
+ this.trackExecutionPauses();
380
397
  this.publish(n);
381
- }).catch(e => { this.cancelTree(n, String(e).slice(0, 1000), "failed"); })
398
+ }).catch(e => {
399
+ if (terminal(n))
400
+ return;
401
+ n.result = String(e).slice(0, 1000);
402
+ if (this.children(n.id).length > childCount) {
403
+ n.status = "waiting";
404
+ this.trackExecutionPauses();
405
+ this.publish(n);
406
+ }
407
+ else
408
+ this.cancelTree(n, n.result, "failed");
409
+ })
382
410
  .finally(() => this.controllers.delete(n.id));
383
411
  }
384
412
  }
@@ -0,0 +1,20 @@
1
+ // GET /api/bots/:id/slack-management: where, in the organisation's Admin, this
2
+ // bot's Slack app is managed. A deep link only: this server holds no Slack
3
+ // logic, contacts nobody, and the link carries identifiers, never a credential.
4
+ // Client-scoped in server/request-auth.ts, so a member reading Bot Settings
5
+ // gets the same link; Admin authorizes whoever opens it.
6
+ import { hostedSlackManagement } from "../hosted-slack.js";
7
+ import { PASS } from "./table.js";
8
+ export function createHostedSlackRoutes(deps) {
9
+ return async ({ res, path, method, json }) => {
10
+ const m = path.match(/^\/api\/bots\/([\w-]+)\/slack-management$/);
11
+ if (!m || method !== "GET")
12
+ return PASS;
13
+ const bot = deps.bot(m[1]);
14
+ if (!bot || bot.hidden)
15
+ return json(res, 404, { error: "no such bot" });
16
+ res.setHeader("cache-control", "no-store");
17
+ // The id in the link is the stored bot's, not the text from the request.
18
+ return json(res, 200, hostedSlackManagement(bot.id, deps.hostedReady(), deps.env));
19
+ };
20
+ }
@@ -0,0 +1,14 @@
1
+ /** "Not my route": the next handler, then index.ts's inline routes, get a turn. */
2
+ export const PASS = Symbol("route.pass");
3
+ export const ROUTES = [];
4
+ /** Runs handlers in order until one answers; true means stop routing. A
5
+ * handler that wrote a response but returned PASS by mistake still counts as
6
+ * answered, so the request can never reach a second handler. */
7
+ export async function dispatchRoutes(routes, ctx) {
8
+ for (const route of routes) {
9
+ const out = await route(ctx);
10
+ if (out !== PASS || ctx.res.headersSent || ctx.res.writableEnded)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { z } from "zod";
3
4
  import { cronScheduleLabel } from "../shared/cron-label.js";
4
5
  import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
@@ -59,6 +60,7 @@ const routineToolDefinitionSchema = z.object({
59
60
  durationMinutes: z.number().optional(),
60
61
  timeoutMinutes: z.number().nullable().optional(),
61
62
  continuity: z.boolean().optional(),
63
+ overlap: z.enum(["skip", "queue"]).optional(),
62
64
  }).strict();
63
65
  const routineToolChangesSchema = routineToolDefinitionSchema
64
66
  .omit({ timeoutMinutes: true })
@@ -174,6 +176,7 @@ const storedDefinitionSchema = z.object({
174
176
  durationMinutes: z.number().int().min(5).max(240),
175
177
  timeoutMinutes: z.number().int().min(5).max(240).optional(),
176
178
  continuity: z.boolean().optional(),
179
+ overlap: z.enum(["skip", "queue"]).optional(),
177
180
  }).strict();
178
181
  const storedChangesSchema = storedDefinitionSchema
179
182
  .omit({ schedule: true, timeoutMinutes: true })
@@ -382,6 +385,7 @@ function normalizeDefinition(input, now) {
382
385
  durationMinutes: duration(input.durationMinutes),
383
386
  ...(timeoutMinutes == null ? {} : { timeoutMinutes }),
384
387
  ...(input.continuity === true ? { continuity: true } : {}),
388
+ ...(input.overlap === "queue" ? { overlap: "queue" } : {}),
385
389
  };
386
390
  }
387
391
  function normalizeChanges(input, now) {
@@ -400,6 +404,8 @@ function normalizeChanges(input, now) {
400
404
  changes.timeoutMinutes = timeout(input.timeoutMinutes);
401
405
  if (input.continuity !== undefined)
402
406
  changes.continuity = input.continuity === true;
407
+ if (input.overlap !== undefined)
408
+ changes.overlap = input.overlap;
403
409
  return changes;
404
410
  }
405
411
  function routineId(value) {
@@ -619,6 +625,7 @@ function effectiveDefinition(operation, manager) {
619
625
  durationMinutes: existing.durationMinutes,
620
626
  ...(existing.timeoutMinutes === undefined ? {} : { timeoutMinutes: existing.timeoutMinutes }),
621
627
  ...(existing.continuity ? { continuity: true } : {}),
628
+ ...(existing.overlap ? { overlap: existing.overlap } : {}),
622
629
  };
623
630
  if (operation.action !== "update")
624
631
  return base;
@@ -654,7 +661,7 @@ function cardCopy(operation, manager, timeZone, now) {
654
661
  const nextRunAt = nextForOperation(operation, manager, now);
655
662
  const scheduleTimeZone = definition.schedule.type === "cron" ? definition.schedule.timeZone : timeZone;
656
663
  const when = operation.action === "run_now" ? "Now" : scheduleText(definition.schedule, timeZone);
657
- const destination = definition.runOn === "cloud" ? "Cloud VM" : "This OpenMausBot setup";
664
+ const destination = definition.runOn === "cloud" ? "Box-hosted agent" : "Bot’s current model and configured computer";
658
665
  const current = operation.action === "create"
659
666
  ? null
660
667
  : manager.listRoutines().find((routine) => routine.id === operation.routineId) ?? null;
@@ -699,6 +706,7 @@ function cardCopy(operation, manager, timeZone, now) {
699
706
  `Runs on: ${destination}`,
700
707
  `Run limit: ${definition.timeoutMinutes === undefined ? "No limit" : `${definition.timeoutMinutes} minutes`}`,
701
708
  `Continuity: ${definition.continuity ? "Carries the previous run's report into the next run" : "Each run starts fresh"}`,
709
+ `While busy: ${definition.overlap === "queue" ? "Queue one scheduled run; skip further occurrences until it starts" : "Skip overlapping scheduled occurrences"}`,
702
710
  // Last before the instructions: the one sentence that says what
703
711
  // confirming actually does, in the reader's terms.
704
712
  ...(operation.action === "create" || operation.action === "update"
@@ -723,6 +731,7 @@ function inputFromDefinition(definition, botId, now) {
723
731
  durationMinutes: definition.durationMinutes,
724
732
  ...(definition.timeoutMinutes === undefined ? {} : { timeoutMinutes: definition.timeoutMinutes }),
725
733
  ...(definition.continuity ? { continuity: true } : {}),
734
+ ...(definition.overlap === "queue" ? { overlap: "queue" } : {}),
726
735
  };
727
736
  }
728
737
  function updateFromChanges(changes, now, currentSchedule) {
@@ -741,6 +750,8 @@ function updateFromChanges(changes, now, currentSchedule) {
741
750
  patch.timeoutMinutes = changes.timeoutMinutes;
742
751
  if (changes.continuity !== undefined)
743
752
  patch.continuity = changes.continuity;
753
+ if (changes.overlap !== undefined)
754
+ patch.overlap = changes.overlap;
744
755
  return patch;
745
756
  }
746
757
  function canonicalValue(value) {
@@ -793,6 +804,33 @@ function requestCommit(payload, messageId) {
793
804
  };
794
805
  }
795
806
  function revalidateOperation(operation, manager, botId, now) {
807
+ if (operation.action === "create") {
808
+ const definition = operation.routine;
809
+ const owner = operation.forBot?.botId ?? botId;
810
+ const schedule = asSchedule(definition.schedule, now);
811
+ const duplicate = manager.listRoutines().find((routine) => {
812
+ if (!routine.enabled || routine.target !== "bot" || routine.botId !== owner
813
+ || routine.runOn !== definition.runOn || routine.prompt !== definition.instructions
814
+ || routine.durationMinutes !== definition.durationMinutes
815
+ || routine.timeoutMinutes !== definition.timeoutMinutes
816
+ || Boolean(routine.continuity) !== Boolean(definition.continuity)
817
+ || (routine.overlap ?? "skip") !== (definition.overlap ?? "skip")
818
+ || (routine.attachments?.length ?? 0) > 0)
819
+ return false;
820
+ // An omitted start means "every N minutes", not a new phase each time
821
+ // the model retries. Explicit starts and all other constraints stay exact.
822
+ const candidate = schedule.type === "interval" && routine.schedule.type === "interval"
823
+ && definition.schedule.type === "interval" && definition.schedule.anchorAt === undefined
824
+ ? { ...schedule, anchorAt: routine.schedule.anchorAt }
825
+ : schedule;
826
+ return isDeepStrictEqual(candidate, routine.schedule);
827
+ });
828
+ if (duplicate) {
829
+ // The tool cannot choose a result destination. Return the existing ID,
830
+ // without moving its reports or treating a renamed request as new work.
831
+ throw new RoutineRequestError(`An enabled routine with the same instructions and execution settings already exists (${duplicate.id}). Use list_routines to review it, then update or run that routine instead.`, 409);
832
+ }
833
+ }
796
834
  const current = operation.action === "create"
797
835
  ? null
798
836
  : verifyManageSnapshot(operation, manager, botId);
@@ -6,6 +6,7 @@ import { DATA_DIR } from "./config.js";
6
6
  import { writeFileAtomic } from "./atomic.js";
7
7
  import { redactSecretsInText } from "./redact.js";
8
8
  import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
9
+ import { isRoutineProblemRun } from "../shared/routines.js";
9
10
  const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
10
11
  const webhookRunReceiptSchema = z.object({
11
12
  webhookId: z.string().min(1).max(200),
@@ -432,6 +433,9 @@ function sanitizeInput(input, after) {
432
433
  throw new Error("Attachments can only run on this computer until cloud file staging is available");
433
434
  }
434
435
  const continuity = input.continuity === true;
436
+ if (input.overlap !== undefined && input.overlap !== "skip" && input.overlap !== "queue") {
437
+ throw new Error("Choose skip or queue for overlapping runs");
438
+ }
435
439
  if (continuity && target === "room-goal") {
436
440
  throw new Error("Room goals do not carry continuity yet");
437
441
  }
@@ -448,6 +452,7 @@ function sanitizeInput(input, after) {
448
452
  ...(timeoutMinutes === undefined ? {} : { timeoutMinutes }),
449
453
  attachments,
450
454
  ...(continuity ? { continuity: true } : {}),
455
+ ...(input.overlap === "queue" ? { overlap: "queue" } : {}),
451
456
  };
452
457
  }
453
458
  export class RoutineManager {
@@ -482,9 +487,13 @@ export class RoutineManager {
482
487
  attachments: loadAttachments(routine.attachments),
483
488
  sourceThreadId: persistedSourceThreadId.parse(routine.sourceThreadId),
484
489
  resultsThreadId: persistedSourceThreadId.parse(routine.resultsThreadId),
490
+ overlap: routine.overlap === "queue" ? "queue" : undefined,
491
+ skippedRuns: Number.isSafeInteger(routine.skippedRuns) && routine.skippedRuns > 0 ? routine.skippedRuns : undefined,
492
+ lastSkippedAt: Number.isSafeInteger(routine.lastSkippedAt) && routine.lastSkippedAt >= 0 && routine.lastSkippedAt <= MAX_DATE_MS ? routine.lastSkippedAt : undefined,
485
493
  };
486
494
  if (loaded.timeoutMinutes === undefined)
487
495
  delete loaded.timeoutMinutes;
496
+ delete loaded.failureStreak;
488
497
  return [loaded];
489
498
  })
490
499
  : [];
@@ -564,7 +573,16 @@ export class RoutineManager {
564
573
  }
565
574
  }
566
575
  listRoutines() {
567
- return this.routines.map(cloneRoutine);
576
+ return this.routines.map(routine => this.routineWithHealth(routine));
577
+ }
578
+ routineWithHealth(routine) {
579
+ // Successful delegated/provider turns are not successful runs until the
580
+ // routine settles. Cancellation and missed schedules are not attempts.
581
+ const outcomes = this.runs.filter(run => run.routineId === routine.id && (run.status === "failed" || run.status === "completed")).reverse()
582
+ .sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt) || b.createdAt - a.createdAt);
583
+ const success = outcomes.findIndex(run => run.status === "completed");
584
+ const failures = success < 0 ? outcomes.length : success;
585
+ return { ...cloneRoutine(routine), ...(failures ? { failureStreak: failures } : {}) };
568
586
  }
569
587
  /** Whether this computer should stay awake for routines: a run is in
570
588
  * flight, or an enabled routine is due within the horizon. The schedule
@@ -664,7 +682,7 @@ export class RoutineManager {
664
682
  if (receipt) {
665
683
  const committed = this.routines.find((routine) => routine.id === receipt.resultId);
666
684
  if (committed)
667
- return cloneRoutine(committed);
685
+ return this.routineWithHealth(committed);
668
686
  throw new Error("This routine request was already applied");
669
687
  }
670
688
  }
@@ -693,14 +711,14 @@ export class RoutineManager {
693
711
  this.rememberRoutineRequest(request, routine.id, at);
694
712
  }, discardResults);
695
713
  this.emitRoutine(routine);
696
- return cloneRoutine(routine);
714
+ return this.routineWithHealth(routine);
697
715
  }
698
716
  update(id, patch, request) {
699
717
  if (request) {
700
718
  const receipt = this.matchingRoutineRequestReceipt(request);
701
719
  if (receipt) {
702
720
  const committed = this.routines.find((routine) => routine.id === receipt.resultId);
703
- return committed ? cloneRoutine(committed) : null;
721
+ return committed ? this.routineWithHealth(committed) : null;
704
722
  }
705
723
  }
706
724
  const routine = this.routines.find((r) => r.id === id);
@@ -720,6 +738,7 @@ export class RoutineManager {
720
738
  timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
721
739
  attachments: patch.attachments ?? routine.attachments,
722
740
  continuity: patch.continuity ?? routine.continuity,
741
+ overlap: Object.hasOwn(patch, "overlap") ? patch.overlap : routine.overlap,
723
742
  }, now);
724
743
  if (this.targetState(clean) === "missing")
725
744
  throw new Error(this.missingTargetMessage(clean.target));
@@ -749,6 +768,8 @@ export class RoutineManager {
749
768
  // rather than false, so switching continuity off has to delete it.
750
769
  if (!clean.continuity)
751
770
  delete routine.continuity;
771
+ if (clean.overlap !== "queue")
772
+ delete routine.overlap;
752
773
  if (Object.hasOwn(patch, "timeoutMinutes") && patch.timeoutMinutes == null) {
753
774
  delete routine.timeoutMinutes;
754
775
  }
@@ -769,7 +790,7 @@ export class RoutineManager {
769
790
  for (const run of cancelledRuns)
770
791
  this.emitRun(run);
771
792
  this.emitRoutine(routine);
772
- return cloneRoutine(routine);
793
+ return this.routineWithHealth(routine);
773
794
  }
774
795
  remove(id, request) {
775
796
  if (request) {
@@ -1043,6 +1064,27 @@ export class RoutineManager {
1043
1064
  }
1044
1065
  return cloneRun(run);
1045
1066
  }
1067
+ /** Clear every failure indicator at once: stamp seenAt on each unseen
1068
+ * failed/missed run in one committed save, then emit the updated runs so
1069
+ * connected clients drop their dots immediately. A failed save rolls the
1070
+ * stamps back so a retry still finds the unseen runs. */
1071
+ markAllSeen() {
1072
+ if (!this.runs.some((run) => !run.seenAt && isRoutineProblemRun(run)))
1073
+ return [];
1074
+ const stampAt = this.now();
1075
+ const updated = [];
1076
+ this.commitMutation(() => {
1077
+ for (const run of this.runs) {
1078
+ if (run.seenAt || !isRoutineProblemRun(run))
1079
+ continue;
1080
+ run.seenAt = stampAt;
1081
+ updated.push(run);
1082
+ }
1083
+ });
1084
+ for (const run of updated)
1085
+ this.emitRun(run);
1086
+ return updated.map(cloneRun);
1087
+ }
1046
1088
  get isTicking() { return this.ticking; }
1047
1089
  start() {
1048
1090
  if (this.timer)
@@ -1098,8 +1140,10 @@ export class RoutineManager {
1098
1140
  : pendingAt;
1099
1141
  // Frequent recurring work must not build an unbounded queue of stale
1100
1142
  // copies. Elapsed intervals keep their phase; cron keeps its calendar.
1101
- const overlapping = (routine.schedule.type === "interval" || routine.schedule.type === "cron") && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1102
- if (!overlapping) {
1143
+ const overlapping = routine.schedule.type !== "once" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1144
+ const scheduledQueued = this.runs.some(run => run.routineId === routine.id && run.status === "queued" &&
1145
+ (run.triggerSource ?? (run.manual ? "manual" : "schedule")) === "schedule");
1146
+ if (!overlapping || (routine.overlap === "queue" && !scheduledQueued)) {
1103
1147
  const run = this.newRun(routine, scheduledFor, false, allocations);
1104
1148
  if (late > CATCH_UP_MS) {
1105
1149
  run.status = "missed";
@@ -1108,6 +1152,10 @@ export class RoutineManager {
1108
1152
  }
1109
1153
  scheduledRuns.push(run);
1110
1154
  }
1155
+ else {
1156
+ routine.skippedRuns = Math.min(Number.MAX_SAFE_INTEGER, (routine.skippedRuns ?? 0) + 1);
1157
+ routine.lastSkippedAt = scheduledFor;
1158
+ }
1111
1159
  routine.nextRunAt =
1112
1160
  routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
1113
1161
  // `updatedAt` is the optimistic definition revision carried by
@@ -1170,7 +1218,10 @@ export class RoutineManager {
1170
1218
  continue;
1171
1219
  }
1172
1220
  }
1173
- const state = this.targetState(run);
1221
+ // A bot can have spare thread slots while this routine is waiting on
1222
+ // a teammate. Queue means after THIS run, not merely a free bot slot.
1223
+ const sameRoutineWorking = triggerSource === "schedule" && this.runs.some(other => other.id !== run.id && other.routineId === run.routineId && ["running", "waiting"].includes(other.status));
1224
+ const state = sameRoutineWorking ? "busy" : this.targetState(run);
1174
1225
  if (state === "busy") {
1175
1226
  // A queued run behind a busy target is deferred, not silent. Stamp
1176
1227
  // the wait once so receipts and cards can say how long it has been
@@ -1453,11 +1504,16 @@ export class RoutineManager {
1453
1504
  routine.resultsThreadId = value.trim();
1454
1505
  }
1455
1506
  emitRoutine(routine) {
1456
- this.options.emit?.({ kind: "routine", routine: cloneRoutine(routine) });
1507
+ this.options.emit?.({ kind: "routine", routine: this.routineWithHealth(routine) });
1457
1508
  }
1458
1509
  emitRun(run) {
1459
1510
  this.options.emit?.({ kind: "routine.run", run: cloneRun(run) });
1460
1511
  this.notifyRunChanged(run);
1512
+ if (run.status === "completed" || run.status === "failed") {
1513
+ const routine = this.routines.find(candidate => candidate.id === run.routineId);
1514
+ if (routine)
1515
+ this.emitRoutine(routine);
1516
+ }
1461
1517
  }
1462
1518
  notifyRunChanged(run) {
1463
1519
  try {
@@ -1,5 +1,6 @@
1
1
  /** A local desktop call competes with ordinary bot turns for the same screen.
2
2
  * Leases expire if Electron crashes; human takeover always wins renewal. */
3
+ const LEASE_EXPIRY_MS = 40_000;
3
4
  export class SharedComputerControl {
4
5
  leases = new Map();
5
6
  resources;
@@ -11,12 +12,38 @@ export class SharedComputerControl {
11
12
  this.release(id);
12
13
  throw Object.assign(new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."), { status: 409 });
13
14
  }
14
- clearTimeout(this.leases.get(id));
15
- this.leases.set(id, setTimeout(() => this.release(id), 40_000));
15
+ this.arm(id);
16
+ }
17
+ /** Extends a held lease for one long desktop action without re-running the
18
+ * takeover checks: a heartbeat while the operation is pending must not be
19
+ * able to lose its own screen. Renewing an expired or released id is a
20
+ * no-op — the lease is gone and only acquire can bring it back. */
21
+ renew(id) {
22
+ if (this.leases.has(id))
23
+ this.arm(id);
24
+ }
25
+ /** (Re)arm the expiry timer. The generation counter makes a stale timer a
26
+ * no-op: only the callback of the newest arming may release the lease. */
27
+ arm(id) {
28
+ const previous = this.leases.get(id);
29
+ if (previous)
30
+ clearTimeout(previous.timer);
31
+ const generation = (previous?.generation ?? 0) + 1;
32
+ this.leases.set(id, {
33
+ timer: setTimeout(() => {
34
+ if (this.leases.get(id)?.generation === generation)
35
+ this.release(id);
36
+ }, LEASE_EXPIRY_MS),
37
+ generation,
38
+ lastActivity: Date.now(),
39
+ });
16
40
  }
17
41
  release(id) {
18
- clearTimeout(this.leases.get(id));
19
- this.leases.delete(id);
42
+ const lease = this.leases.get(id);
43
+ if (lease) {
44
+ clearTimeout(lease.timer);
45
+ this.leases.delete(id);
46
+ }
20
47
  this.resources.release({ threadId: `shared:${id}`, generation: id });
21
48
  }
22
49
  close() { for (const id of this.leases.keys())
@@ -1,33 +1,76 @@
1
+ // Spend limits over the usage ledger: what this workspace has spent this
2
+ // month against its cap, the refusal a turn gets once the cap is reached,
3
+ // and the one-per-month notices when the month crosses the warning
4
+ // threshold and the cap. The figure is every cost in the ledger: what
5
+ // engines reported (on a workspace of personal subscriptions, their
6
+ // equivalents too) plus the estimates booked for engines that report tokens
7
+ // but no price (server/model-prices.ts). A turn on an unpriced model counts
8
+ // nothing. Enforced only with the `budgets` entitlement; without it the
9
+ // setting is inert.
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
1
12
  import { entitled } from "./enterprise.js";
2
- import { readUsage } from "./usage-ledger.js";
13
+ import { readUsage, usageRowKey } from "./usage-ledger.js";
3
14
  const DEFAULT_WARN_AT_PERCENT = 80;
4
15
  const CACHE_MS = 15_000;
5
16
  // Every turn start asks; reading the month file each time would be silly.
17
+ // A read keeps the keys of the rows it summed, so a booked row can be told
18
+ // apart from the same row read back later.
6
19
  const cache = new Map();
20
+ // Booked turns whose ledger row may not be on disk yet: dataDir → row key →
21
+ // cost. The append is asynchronous, and a turn that ran longer than CACHE_MS
22
+ // would otherwise be re-read out of the month before its own row landed.
23
+ const unwritten = new Map();
7
24
  function monthOf(now) {
8
25
  return now.toISOString().slice(0, 7);
9
26
  }
10
- /** Reported cost this month so far, from the ledger, cached briefly. */
27
+ const costOf = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
28
+ /** Reported and estimated cost this month so far: the ledger file (read at
29
+ * most every CACHE_MS) plus turns booked since whose rows have not landed. */
11
30
  export function monthToDateSpend(dataDir, now = new Date()) {
12
31
  const month = monthOf(now);
13
- const hit = cache.get(dataDir);
14
- if (hit && hit.month === month && now.getTime() - hit.at < CACHE_MS)
15
- return hit.spentUsd;
16
- const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
17
- const spentUsd = readUsage(dataDir, { from, to: now }).reduce((sum, row) => sum + (typeof row.costUsd === "number" && Number.isFinite(row.costUsd) ? row.costUsd : 0), 0);
18
- cache.set(dataDir, { at: now.getTime(), month, spentUsd });
19
- return spentUsd;
20
- }
21
- /** Called right after a turn is booked, so the next check sees it without
22
- * waiting for the ledger's append to land or the cache to expire. */
23
- export function noteSpend(dataDir, costUsd, now = new Date()) {
24
- const hit = cache.get(dataDir);
25
- if (hit && hit.month === monthOf(now) && typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd > 0) {
26
- hit.spentUsd += costUsd;
32
+ let read = cache.get(dataDir);
33
+ if (!read || read.month !== month || now.getTime() - read.at >= CACHE_MS) {
34
+ const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
35
+ const rows = readUsage(dataDir, { from, to: now });
36
+ read = { at: now.getTime(), month, fileUsd: rows.reduce((sum, row) => sum + costOf(row.costUsd), 0), keys: new Set(rows.map(usageRowKey)) };
37
+ cache.set(dataDir, read);
27
38
  }
39
+ let pending = 0;
40
+ for (const [key, entry] of unwritten.get(dataDir) ?? []) {
41
+ if (entry.month === month && !read.keys.has(key))
42
+ pending += entry.costUsd;
43
+ }
44
+ return read.fileUsd + pending;
45
+ }
46
+ /** Called as a turn is booked, with the ledger's write for its row
47
+ * (appendUsage's promise). The cost counts from memory at once; when the
48
+ * row lands it moves into the cached read, and a re-read that already sees
49
+ * the row never counts it twice. A write that failed stops counting, as
50
+ * the ledger has no row for it. */
51
+ export function noteSpend(dataDir, row, written) {
52
+ const costUsd = costOf(row.costUsd);
53
+ if (!costUsd)
54
+ return;
55
+ const key = usageRowKey(row);
56
+ const month = row.at.slice(0, 7);
57
+ const entries = unwritten.get(dataDir) ?? new Map();
58
+ unwritten.set(dataDir, entries);
59
+ entries.set(key, { month, costUsd });
60
+ void written.then((landed) => {
61
+ entries.delete(key);
62
+ if (entries.size === 0 && unwritten.get(dataDir) === entries)
63
+ unwritten.delete(dataDir);
64
+ const read = cache.get(dataDir);
65
+ if (landed && read && read.month === month && !read.keys.has(key)) {
66
+ read.fileUsd += costUsd;
67
+ read.keys.add(key);
68
+ }
69
+ });
28
70
  }
29
71
  export function resetSpendCacheForTests() {
30
72
  cache.clear();
73
+ unwritten.clear();
31
74
  }
32
75
  /** The cap and where the month stands against it; null when there is no
33
76
  * enforceable cap (no entitlement, or none set). */
@@ -59,3 +102,55 @@ export function assertWithinBudget(cfg, dataDir, now = new Date(), isEntitled =
59
102
  return;
60
103
  throw Object.assign(new Error(`this workspace has reached its monthly spend limit of $${usd(state.monthlyUsd)} — an admin can raise it under Settings → Usage`), { status: 409, code: "spend_cap" });
61
104
  }
105
+ const ALERTS_FILE = "alerts.json";
106
+ function readMarks(dataDir) {
107
+ try {
108
+ const value = JSON.parse(readFileSync(join(dataDir, "usage", ALERTS_FILE), "utf8"));
109
+ if (typeof value !== "object" || value === null)
110
+ return null;
111
+ const marks = value;
112
+ return typeof marks.month === "string" && typeof marks.monthlyUsd === "number" ? marks : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** The notice this booking should raise, if any: the cap the first time the
119
+ * month reaches it, the warning the first time it crosses the threshold, and
120
+ * nothing again for either that month. A new month, or a different cap, is a
121
+ * new budget and starts over. Marks live beside the ledger so a restart does
122
+ * not repeat them; if they cannot be written the notice is still sent once
123
+ * for this run. */
124
+ export function takeSpendAlert(dataDir, state) {
125
+ if (!state || (!state.warn && !state.exceeded))
126
+ return null;
127
+ const saved = readMarks(dataDir) ?? memoryMarks.get(dataDir) ?? null;
128
+ const marks = saved && saved.month === state.month && saved.monthlyUsd === state.monthlyUsd
129
+ ? saved
130
+ : { month: state.month, monthlyUsd: state.monthlyUsd };
131
+ const alert = state.exceeded && !marks.cap ? "cap" : state.warn && !marks.warn && !marks.cap ? "warn" : null;
132
+ if (!alert)
133
+ return null;
134
+ // Reaching the cap also answers the warning: one notice, the stronger one.
135
+ const next = { ...marks, warn: true, ...(alert === "cap" ? { cap: true } : {}) };
136
+ memoryMarks.set(dataDir, next);
137
+ try {
138
+ mkdirSync(join(dataDir, "usage"), { recursive: true, mode: 0o700 });
139
+ writeFileSync(join(dataDir, "usage", ALERTS_FILE), JSON.stringify(next), { mode: 0o600 });
140
+ }
141
+ catch {
142
+ /* bookkeeping must never take down the turn */
143
+ }
144
+ return alert;
145
+ }
146
+ const memoryMarks = new Map();
147
+ export function resetSpendAlertsForTests() {
148
+ memoryMarks.clear();
149
+ }
150
+ /** The notice's words. English like the server's other notification titles. */
151
+ export function spendAlertText(alert, state) {
152
+ const spent = `$${state.spentUsd.toFixed(2)} of $${usd(state.monthlyUsd)} spent this month (${state.month})`;
153
+ return alert === "cap"
154
+ ? { title: "Monthly spend limit reached", body: `${spent}. New turns are refused until an admin raises the limit under Settings → Usage.` }
155
+ : { title: `Spend is at ${state.percent}% of the monthly limit`, body: `${spent}. New turns stop when the limit is reached.` };
156
+ }