openmausbot 0.1.85 → 0.1.87

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 (262) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/dist/assets/abnfDiagram-O67JEVCF-BTiuL79F.js +1 -0
  62. package/dist/assets/arc-C88Blc2v.js +1 -0
  63. package/dist/assets/architectureDiagram-NJMV4G6O-BZsJIZrb.js +36 -0
  64. package/dist/assets/blockDiagram-BEXU5L5S-CVeMe9Ah.js +140 -0
  65. package/dist/assets/c4Diagram-YGBWAQC7-Cedk3Bhx.js +38 -0
  66. package/dist/assets/channel-taCNRlUU.js +1 -0
  67. package/dist/assets/chunk-2Q5K7J3B-D-VRqzTB.js +1 -0
  68. package/dist/assets/chunk-3FUC2YCW-BChov01Y.js +2 -0
  69. package/dist/assets/chunk-5VM5RSS4-C-TfwvHy.js +15 -0
  70. package/dist/assets/chunk-GTNCS2PH-Db7CGj5Y.js +88 -0
  71. package/dist/assets/chunk-GWA4HPMP-CNvIfqKT.js +1 -0
  72. package/dist/assets/chunk-JWPE2WC7-C_bEp5tq.js +1 -0
  73. package/dist/assets/chunk-NETBCI7D-Coi4BOls.js +1 -0
  74. package/dist/assets/chunk-WEXAMYUT-CzylEuL3.js +1 -0
  75. package/dist/assets/chunk-XXDRQBXY-m7p7ztaD.js +1 -0
  76. package/dist/assets/classDiagram-v2-NBCMYWYE-BMy6o9x2.js +217 -0
  77. package/dist/assets/cose-bilkent-JH36ORCC-BDQ__0_J.js +1 -0
  78. package/dist/assets/cynefin-OW5HDTMX-Djv4ghUb.js +166 -0
  79. package/dist/assets/cynefinDiagram-VND7K2PF-BULrlGYH.js +62 -0
  80. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  81. package/dist/assets/dagre-6A5THRUB-D-jrT_hU.js +4 -0
  82. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  83. package/dist/assets/diagram-22UHCM2B-p2UhQmfL.js +200 -0
  84. package/dist/assets/diagram-3UASUU5V-DHvGWZ7q.js +24 -0
  85. package/dist/assets/diagram-ATOU4E4O-ChhKDot-.js +3 -0
  86. package/dist/assets/diagram-CDSNMT55-B-T7A43R.js +30 -0
  87. package/dist/assets/diagram-MLGK6HIB-CJm8ENNY.js +24 -0
  88. package/dist/assets/diagram-MPIPVDR6-510tlza8.js +41 -0
  89. package/dist/assets/ebnfDiagram-ZINNZB2B-PkvRPHel.js +1 -0
  90. package/dist/assets/elk-276RUBZZ-BVBVPoX3.js +27 -0
  91. package/dist/assets/erDiagram-OPXOYQCR-BgYMVm7L.js +99 -0
  92. package/dist/assets/flowDiagram-KWPJA3E3-BGqsnc_3.js +213 -0
  93. package/dist/assets/ganttDiagram-FUAMR5RP-D_-UeReH.js +292 -0
  94. package/dist/assets/gitGraphDiagram-X574FWY7-D0uooONx.js +106 -0
  95. package/dist/assets/graph-D8UmR1vK.js +1 -0
  96. package/dist/assets/index-BwdMAQ6p.css +1 -0
  97. package/dist/assets/index-ByJennr7.js +581 -0
  98. package/dist/assets/{index-CTNNoqSJ.js → index-zRsJERGJ.js} +1 -1
  99. package/dist/assets/infoDiagram-VRGFBTTK-B9e1z77f.js +2 -0
  100. package/dist/assets/init-Gi6I4Gst.js +1 -0
  101. package/dist/assets/ishikawaDiagram-OU5B5YK6-BB4vNwvD.js +70 -0
  102. package/dist/assets/journeyDiagram-ZHPQQLJL-DpkosOTB.js +139 -0
  103. package/dist/assets/kanban-definition-PNTS6WVX-DbDP4kUu.js +89 -0
  104. package/dist/assets/linear-DBJGPmjT.js +1 -0
  105. package/dist/assets/mermaid.core-DWPRi-ue.js +311 -0
  106. package/dist/assets/mindmap-definition-NLK3R4M7-nszBbJmx.js +96 -0
  107. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  108. package/dist/assets/pegDiagram-GJSIUBJH-CWykJWu_.js +1 -0
  109. package/dist/assets/pieDiagram-5QR66LMP-g3Da-Vnv.js +39 -0
  110. package/dist/assets/quadrantDiagram-O4NWA36T-Wzrlpzgh.js +7 -0
  111. package/dist/assets/railroadDiagram-XR7U4H2S-moHTFMC0.js +1 -0
  112. package/dist/assets/requirementDiagram-PLB6GJNP-CwfXdFH8.js +84 -0
  113. package/dist/assets/sankeyDiagram-IPEJSGJF-3ZROYvnq.js +40 -0
  114. package/dist/assets/sequenceDiagram-PO4LG4MO-E1TBebP8.js +169 -0
  115. package/dist/assets/sizeCapture-INFHLROL-DHKEU5pu.js +1 -0
  116. package/dist/assets/stateDiagram-v2-GCMORJYK-BguEj09D.js +291 -0
  117. package/dist/assets/swimlanes-2SLR337P-C7IDYnhd.js +1 -0
  118. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  119. package/dist/assets/timeline-definition-EJHVYXUP-DCW-F5PW.js +120 -0
  120. package/dist/assets/usecaseDiagram-POWQR4AR-xBMFtWYV.js +370 -0
  121. package/dist/assets/vennDiagram-UO4OBE2U-B5nIkuH8.js +34 -0
  122. package/dist/assets/wardleyDiagram-VNRHLVJA-DYFAhdbr.js +78 -0
  123. package/dist/assets/xychartDiagram-PMCCYNJV-DeXwX6Cz.js +7 -0
  124. package/dist/index.html +2 -2
  125. package/dist-server/browser-proxy.js +2 -1
  126. package/dist-server/container-mcp.js +1047 -173
  127. package/dist-server/drivers/agents-proxy.js +16121 -1504
  128. package/dist-server/drivers/phone-proxy.js +33 -2
  129. package/dist-server/enterprise/LICENSE +32 -0
  130. package/dist-server/enterprise/server/index.js +5 -5
  131. package/dist-server/index.js +16134 -7446
  132. package/dist-server/local-computer-proxy.js +3 -3
  133. package/dist-server/local-computer.js +53 -17
  134. package/dist-server/mcp-server.js +3 -1
  135. package/dist-server/openmausbot.js +9353 -6124
  136. package/dist-server/pair-cli.js +9353 -6124
  137. package/dist-server/permission-proxy.js +29 -9
  138. package/dist-server/server/admin-activity.js +410 -0
  139. package/dist-server/server/attachments.js +55 -0
  140. package/dist-server/server/auto-approve.js +4 -3
  141. package/dist-server/server/auto-vm-claims.js +1 -0
  142. package/dist-server/server/bot-package.js +18 -292
  143. package/dist-server/server/bot-profile.js +5 -17
  144. package/dist-server/server/bot-visibility.js +462 -0
  145. package/dist-server/server/box-create-idempotency.js +3 -3
  146. package/dist-server/server/box.js +59 -56
  147. package/dist-server/server/browser-proxy.js +3 -2
  148. package/dist-server/server/browser-runtime.js +38 -2
  149. package/dist-server/server/channel-queue.js +2 -0
  150. package/dist-server/server/cli-prompts.js +24 -2
  151. package/dist-server/server/cli.js +81 -12
  152. package/dist-server/server/command-allowlist.js +154 -0
  153. package/dist-server/server/composio.js +49 -11
  154. package/dist-server/server/computer-wait.js +22 -9
  155. package/dist-server/server/config.js +133 -17
  156. package/dist-server/server/connector-verdict.js +141 -0
  157. package/dist-server/server/container-computer.js +1 -1
  158. package/dist-server/server/decision-log.js +209 -48
  159. package/dist-server/server/default-model-selection.js +29 -3
  160. package/dist-server/server/delegations.js +97 -44
  161. package/dist-server/server/drivers/acp/core.js +163 -33
  162. package/dist-server/server/drivers/acp/grok.js +4 -3
  163. package/dist-server/server/drivers/agents-call.js +1162 -0
  164. package/dist-server/server/drivers/agents-catalog.js +689 -0
  165. package/dist-server/server/drivers/agents-client.js +28 -0
  166. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  167. package/dist-server/server/drivers/boxagent.js +174 -36
  168. package/dist-server/server/drivers/builtIn.js +2 -0
  169. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  170. package/dist-server/server/drivers/chat-images.js +54 -0
  171. package/dist-server/server/drivers/chat-mcp-tools.js +55 -10
  172. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  173. package/dist-server/server/drivers/claude.js +28 -8
  174. package/dist-server/server/drivers/codex-instructions.js +48 -6
  175. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  176. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  177. package/dist-server/server/drivers/codex.js +121 -34
  178. package/dist-server/server/drivers/grok.js +2 -1
  179. package/dist-server/server/drivers/mistral.js +115 -0
  180. package/dist-server/server/drivers/openai-chat.js +155 -32
  181. package/dist-server/server/drivers/openai-compat.js +16 -8
  182. package/dist-server/server/drivers/permission-command.js +35 -0
  183. package/dist-server/server/drivers/phone-proxy.js +47 -2
  184. package/dist-server/server/drivers/pi.js +75 -4
  185. package/dist-server/server/drivers/prompt-split.js +87 -0
  186. package/dist-server/server/enterprise.js +97 -16
  187. package/dist-server/server/external-runtime.js +83 -0
  188. package/dist-server/server/fleet.js +1 -1
  189. package/dist-server/server/group-thread-usage.js +103 -0
  190. package/dist-server/server/guarded-requests.js +60 -0
  191. package/dist-server/server/harness/http.js +61 -0
  192. package/dist-server/server/harness/registry.js +1 -0
  193. package/dist-server/server/hosted-models.js +151 -0
  194. package/dist-server/server/hosted-slack.js +11 -0
  195. package/dist-server/server/index.js +3091 -658
  196. package/dist-server/server/local-computer.js +72 -23
  197. package/dist-server/server/managed-desktop.js +79 -14
  198. package/dist-server/server/managed-policy.js +153 -0
  199. package/dist-server/server/mcp-bridge.js +6 -6
  200. package/dist-server/server/message-db.js +28 -0
  201. package/dist-server/server/model-context-window.js +7 -0
  202. package/dist-server/server/model-prices.js +121 -0
  203. package/dist-server/server/new-bot-defaults.js +115 -0
  204. package/dist-server/server/notify.js +10 -1
  205. package/dist-server/server/org-library.js +1051 -0
  206. package/dist-server/server/package-export.js +627 -39
  207. package/dist-server/server/package-import.js +458 -0
  208. package/dist-server/server/package-parts.js +87 -0
  209. package/dist-server/server/peer-approval.js +16 -7
  210. package/dist-server/server/peer-roster.js +23 -2
  211. package/dist-server/server/presets.js +492 -0
  212. package/dist-server/server/provider-key-check.js +3 -2
  213. package/dist-server/server/published-teams.js +80 -0
  214. package/dist-server/server/recent-work.js +15 -0
  215. package/dist-server/server/request-auth.js +96 -0
  216. package/dist-server/server/room-handoffs.js +44 -14
  217. package/dist-server/server/routes/bot-presets.js +25 -0
  218. package/dist-server/server/routes/hosted-slack.js +20 -0
  219. package/dist-server/server/routes/table.js +14 -0
  220. package/dist-server/server/routine-requests.js +6 -3
  221. package/dist-server/server/routines.js +63 -1
  222. package/dist-server/server/section-context.js +10 -0
  223. package/dist-server/server/skills.js +34 -61
  224. package/dist-server/server/spend.js +111 -16
  225. package/dist-server/server/steer-queue.js +9 -3
  226. package/dist-server/server/store.js +550 -35
  227. package/dist-server/server/surface.js +10 -4
  228. package/dist-server/server/system-prompt.js +12 -4
  229. package/dist-server/server/team-backup.js +16 -3
  230. package/dist-server/server/team-computers.js +10 -0
  231. package/dist-server/server/team-library.js +6 -2
  232. package/dist-server/server/team-manifest.js +4 -3
  233. package/dist-server/server/team-setup-requests.js +16 -5
  234. package/dist-server/server/thread-events.js +17 -1
  235. package/dist-server/server/thread-starters.js +53 -0
  236. package/dist-server/server/turn-context.js +2 -0
  237. package/dist-server/server/turn-watchdog.js +12 -3
  238. package/dist-server/server/usage-ledger.js +43 -9
  239. package/dist-server/server/vps-computer.js +139 -51
  240. package/dist-server/server/vps-container-mcp.js +7 -0
  241. package/dist-server/server/vps-ssh.js +36 -7
  242. package/dist-server/server/workspace-backup-policy.js +21 -2
  243. package/dist-server/server/workspace-backup.js +7 -4
  244. package/dist-server/server/workspace.js +1 -1
  245. package/dist-server/shared/approval-mode.js +1 -1
  246. package/dist-server/shared/ask-question.js +188 -12
  247. package/dist-server/shared/bot-profile.js +15 -0
  248. package/dist-server/shared/command-allowlist.js +1 -0
  249. package/dist-server/shared/credential-request.js +1 -1
  250. package/dist-server/shared/mention-boundary.js +15 -0
  251. package/dist-server/shared/package-format.js +967 -0
  252. package/dist-server/shared/routines.js +7 -1
  253. package/dist-server/shared/skill-md.js +64 -0
  254. package/dist-server/shared/team-backup.js +9 -0
  255. package/dist-server/shared/wire.js +4 -0
  256. package/dist-server/src/lib/composer-attachments.js +16 -0
  257. package/dist-server/vps-container-mcp.js +1147 -179
  258. package/enterprise/README.md +33 -12
  259. package/enterprise/server/index.js +5 -5
  260. package/package.json +1 -1
  261. package/dist/assets/index-Ba9G44HI.js +0 -310
  262. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -15,44 +15,88 @@
15
15
  // credentials included), and fire-and-forget — an audit log must never
16
16
  // take down the decision it is auditing.
17
17
  //
18
+ // Layout and retention follow the usage ledger: one file per UTC month under
19
+ // <data>/decisions/YYYY-MM.ndjson, and a month file is deleted only once the
20
+ // whole month is older than the retention window (default 180 days, config
21
+ // `decisions.retentionDays` or OMB_DECISION_RETENTION_DAYS), so at least that
22
+ // much history is always kept. Older servers wrote one decisions.ndjson that
23
+ // rotated to .1 at 4 MB; both are still read, never written, and removed by
24
+ // the same window once their last row is older than it.
25
+ //
18
26
  // Deliberately NOT covered: ask_bot peer-approval cards. They never cross
19
27
  // the runtime bus (peer-approval.ts appends its cards straight to the
20
28
  // store), so wiring them here would mean a second, parallel tap — a
21
29
  // separate change if it earns its keep.
22
- import { readFileSync } from "node:fs";
23
- import { appendFile, rename, stat } from "node:fs/promises";
24
- import { join } from "node:path";
30
+ import { AsyncLocalStorage } from "node:async_hooks";
31
+ import { readdirSync, readFileSync } from "node:fs";
32
+ import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises";
33
+ import { basename, join } from "node:path";
25
34
  import { redactSecrets } from "./redact.js";
26
- const FILE_NAME = "decisions.ndjson";
27
- // Rotation is logrotate at its simplest: when the live file crosses the cap
28
- // it becomes `.1` (clobbering the previous `.1`) and a fresh file starts.
29
- // Total disk is bounded at ~2× the cap; at a few hundred bytes per row that
30
- // is years of human-scale approvals, and anything fancier — dated segments,
31
- // compression — is more machinery than an audit trail this size warrants.
32
- const MAX_BYTES = 4 * 1024 * 1024;
35
+ import { csvCell } from "./usage-ledger.js";
36
+ const DIR = "decisions";
37
+ const LEGACY_FILE = "decisions.ndjson";
38
+ const MONTH_FILE = /^(\d{4})-(\d{2})\.ndjson$/;
39
+ const DAY_MS = 24 * 60 * 60_000;
40
+ const PRUNE_EVERY_MS = 60 * 60_000;
41
+ export const DEFAULT_DECISION_RETENTION_DAYS = 180;
42
+ export const MAX_DECISION_RETENTION_DAYS = 3650;
33
43
  const writeQueues = new Map();
34
- async function writeDecision(dataDir, row, maxBytes) {
35
- const file = join(dataDir, FILE_NAME);
36
- try {
37
- if ((await stat(file)).size >= maxBytes)
38
- await rename(file, `${file}.1`);
39
- }
40
- catch {
41
- /* no live file yet — nothing to rotate */
44
+ const lastPrune = new Map();
45
+ let retentionDays = () => DEFAULT_DECISION_RETENTION_DAYS;
46
+ /** The server binds its configured window once; see decisionRetentionDays. */
47
+ export function bindDecisionRetention(provider) {
48
+ retentionDays = provider;
49
+ }
50
+ /** The window in force now. The admin activity log (admin-activity.ts)
51
+ * keeps its months for the same time, so one setting covers both. */
52
+ export function boundRetentionDays() {
53
+ return retentionDays();
54
+ }
55
+ /** The retention window in days: OMB_DECISION_RETENTION_DAYS when it is a
56
+ * whole number in range, else the configured value, else 180. */
57
+ export function decisionRetentionDays(configured, env = process.env) {
58
+ const fromEnv = env.OMB_DECISION_RETENTION_DAYS?.trim();
59
+ const parsed = fromEnv ? Number(fromEnv) : NaN;
60
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_DECISION_RETENTION_DAYS)
61
+ return parsed;
62
+ if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= MAX_DECISION_RETENTION_DAYS)
63
+ return configured;
64
+ return DEFAULT_DECISION_RETENTION_DAYS;
65
+ }
66
+ const actorScope = new AsyncLocalStorage();
67
+ /** Run `work` as a person's card answer: every `source: "user"` row it
68
+ * writes names `actor`, without each resolver having to thread it through. */
69
+ export function withDecisionActor(actor, work) {
70
+ return actorScope.run(actor, work);
71
+ }
72
+ function monthKey(at) {
73
+ return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
74
+ }
75
+ export function decisionFileFor(dataDir, at) {
76
+ return join(dataDir, DIR, `${monthKey(at)}.ndjson`);
77
+ }
78
+ async function writeDecision(dataDir, record) {
79
+ const at = new Date(record.at);
80
+ await mkdir(join(dataDir, DIR), { recursive: true, mode: 0o700 });
81
+ await appendFile(decisionFileFor(dataDir, at), JSON.stringify(record) + "\n", { mode: 0o600 });
82
+ if (at.getTime() - (lastPrune.get(dataDir) ?? 0) >= PRUNE_EVERY_MS) {
83
+ lastPrune.set(dataDir, at.getTime());
84
+ await pruneDecisions(dataDir, retentionDays(), at);
42
85
  }
43
- const record = redactSecrets({ at: new Date().toISOString(), ...row });
44
- await appendFile(file, JSON.stringify(record) + "\n", { mode: 0o600 });
45
86
  }
46
87
  /** Append one decision row. Fire-and-forget, mirroring the event bus tee:
47
88
  * the fold that calls this is delivering approvals and cards, and a full
48
89
  * disk must not turn into denied tools. */
49
- export function appendDecision(dataDir, row, opts) {
90
+ export function appendDecision(dataDir, row) {
91
+ const actor = row.actor ?? (row.source === "user" ? actorScope.getStore() : undefined);
92
+ // Redact now, not when the queue drains: the row is what was true at the
93
+ // moment of the decision.
94
+ const record = redactSecrets({ at: new Date().toISOString(), ...row, ...(actor ? { actor } : {}) });
50
95
  const previous = writeQueues.get(dataDir) ?? Promise.resolve();
51
- // Serialize stat → optional rotate → append for each directory. Without
52
- // this queue two simultaneous approvals can both rotate, overwrite .1,
53
- // or append out of decision order.
96
+ // Serialize appends (and the occasional prune) per directory, so two
97
+ // simultaneous approvals keep their decision order.
54
98
  const queued = previous
55
- .then(() => writeDecision(dataDir, row, opts?.maxBytes ?? MAX_BYTES))
99
+ .then(() => writeDecision(dataDir, record))
56
100
  .catch(() => {
57
101
  /* logging must never take down the fold */
58
102
  });
@@ -67,40 +111,157 @@ export function appendDecision(dataDir, row, opts) {
67
111
  export async function flushDecisionLog(dataDir) {
68
112
  await writeQueues.get(dataDir);
69
113
  }
114
+ /** Delete what the window no longer covers: a month file once the first
115
+ * instant after its month is at or before `now - days`, a legacy file once
116
+ * its last write is. Returns the names removed. Never throws. */
117
+ export async function pruneDecisions(dataDir, days, now = new Date()) {
118
+ const cutoff = now.getTime() - days * DAY_MS;
119
+ const removed = await pruneMonthFiles(join(dataDir, DIR), days, now);
120
+ for (const name of [`${LEGACY_FILE}.1`, LEGACY_FILE]) {
121
+ try {
122
+ if ((await stat(join(dataDir, name))).mtimeMs > cutoff)
123
+ continue;
124
+ await unlink(join(dataDir, name));
125
+ removed.push(name);
126
+ }
127
+ catch {
128
+ /* absent */
129
+ }
130
+ }
131
+ return removed;
132
+ }
133
+ /** Delete the `YYYY-MM.ndjson` files in `dir` whose whole month is at or
134
+ * before `now - days`. Returns the names removed. Never throws. Shared with
135
+ * the admin activity log. */
136
+ export async function pruneMonthFiles(dir, days, now = new Date()) {
137
+ const cutoff = now.getTime() - days * DAY_MS;
138
+ const removed = [];
139
+ let names = [];
140
+ try {
141
+ names = await readdir(dir);
142
+ }
143
+ catch {
144
+ /* nothing written in the new layout yet */
145
+ }
146
+ for (const name of names) {
147
+ const match = MONTH_FILE.exec(name);
148
+ if (!match || Date.UTC(Number(match[1]), Number(match[2]), 1) > cutoff)
149
+ continue;
150
+ try {
151
+ await unlink(join(dir, name));
152
+ removed.push(name);
153
+ }
154
+ catch {
155
+ /* already gone, or not ours to remove */
156
+ }
157
+ }
158
+ return removed;
159
+ }
70
160
  const isDecisionRow = (value) => typeof value === "object" &&
71
161
  value !== null &&
72
162
  typeof value.at === "string" &&
73
163
  typeof value.threadId === "string" &&
74
164
  typeof value.decision === "string" &&
75
165
  typeof value.source === "string";
76
- /** The newest `limit` rows, oldest first — the same order the inspector
77
- * uses for thread events. Reads `.1` before the live file so a request
78
- * right after a rotation still sees history instead of a nearly empty
79
- * log; whole-file reads are fine here because rotation caps both files,
80
- * unlike the unbounded per-thread event logs that need a tail walk. */
81
- export function readDecisions(dataDir, limit) {
82
- const file = join(dataDir, FILE_NAME);
166
+ function readRows(path) {
167
+ let text;
168
+ try {
169
+ text = readFileSync(path, "utf8");
170
+ }
171
+ catch {
172
+ return [];
173
+ }
83
174
  const rows = [];
84
- for (const path of [`${file}.1`, file]) {
85
- let text;
175
+ for (const line of text.split("\n")) {
176
+ if (!line)
177
+ continue;
86
178
  try {
87
- text = readFileSync(path, "utf8");
179
+ const value = JSON.parse(line);
180
+ if (isDecisionRow(value))
181
+ rows.push(value);
88
182
  }
89
183
  catch {
90
- continue;
184
+ /* a line torn mid-write — skip the fragment, keep the rest */
91
185
  }
92
- for (const line of text.split("\n")) {
93
- if (!line)
94
- continue;
95
- try {
96
- const value = JSON.parse(line);
97
- if (isDecisionRow(value))
98
- rows.push(value);
99
- }
100
- catch {
101
- /* a line torn mid-write — skip the fragment, keep the rest */
102
- }
186
+ }
187
+ return rows;
188
+ }
189
+ /** Every file that may hold rows, oldest first: the legacy pair (which only
190
+ * ever holds rows older than any month file), then the months in order. */
191
+ function sources(dataDir) {
192
+ let months = [];
193
+ try {
194
+ months = readdirSync(join(dataDir, DIR)).filter((name) => MONTH_FILE.test(name)).sort();
195
+ }
196
+ catch {
197
+ /* no month files yet */
198
+ }
199
+ return [join(dataDir, `${LEGACY_FILE}.1`), join(dataDir, LEGACY_FILE), ...months.map((name) => join(dataDir, DIR, name))];
200
+ }
201
+ /** The newest `limit` rows, oldest first — the same order the inspector
202
+ * uses for thread events. Reads month files newest first and stops once it
203
+ * has enough, so a long history costs no more than the page asked for. */
204
+ export function readDecisions(dataDir, limit) {
205
+ const files = sources(dataDir);
206
+ const chunks = [];
207
+ let count = 0;
208
+ for (let index = files.length - 1; index >= 0 && count < limit; index -= 1) {
209
+ const rows = readRows(files[index]);
210
+ chunks.unshift(rows);
211
+ count += rows.length;
212
+ }
213
+ return chunks.flat().slice(-limit);
214
+ }
215
+ /** Every row whose time falls inside the range, oldest first. */
216
+ export function readDecisionRange(dataDir, range) {
217
+ const from = range.from.getTime();
218
+ const to = range.to.getTime();
219
+ const first = monthKey(range.from);
220
+ const last = monthKey(range.to);
221
+ const rows = [];
222
+ for (const file of sources(dataDir)) {
223
+ // Month files outside the range are skipped unread; legacy files have no month.
224
+ const month = MONTH_FILE.exec(basename(file));
225
+ if (month && (`${month[1]}-${month[2]}` < first || `${month[1]}-${month[2]}` > last))
226
+ continue;
227
+ for (const row of readRows(file)) {
228
+ const at = Date.parse(row.at);
229
+ if (at >= from && at <= to)
230
+ rows.push(row);
103
231
  }
104
232
  }
105
- return rows.slice(-limit);
233
+ return rows;
234
+ }
235
+ export function actorLabel(actor) {
236
+ if (!actor)
237
+ return "";
238
+ if (actor.kind === "loopback")
239
+ return "This computer";
240
+ if (actor.kind === "worker")
241
+ return "Local service";
242
+ return actor.email ?? actor.label;
243
+ }
244
+ /** One line per decision, spreadsheet-ready. Cells go through the usage
245
+ * CSV's formula neutralising, and every row through redaction again, so an
246
+ * export never carries more than the log itself may. */
247
+ export function decisionsCsv(rows) {
248
+ const header = ["time", "decision", "source", "bot", "tool", "summary", "rule", "unattended", "answered_by", "thread", "request"];
249
+ const lines = [header.join(",")];
250
+ for (const raw of rows) {
251
+ const row = redactSecrets(raw);
252
+ lines.push([
253
+ row.at,
254
+ row.decision,
255
+ row.source,
256
+ row.botName || row.botId || "",
257
+ row.tool ?? "",
258
+ row.summary ?? "",
259
+ row.rule ?? "",
260
+ row.unattended ? "yes" : "",
261
+ actorLabel(row.actor),
262
+ row.threadId,
263
+ row.requestId ?? "",
264
+ ].map(csvCell).join(","));
265
+ }
266
+ return lines.join("\n") + "\n";
106
267
  }
@@ -1,10 +1,14 @@
1
+ const claudeFirst = (instances) => instances.find((instance) => instance.driverKind === "claudeAgent") ?? instances[0];
1
2
  /** A saved choice is intentional: an unavailable provider or removed model
2
3
  * sends new bots to setup instead of silently changing their provider. */
3
- export function selectDefaultModelSelection(instances, preferred) {
4
+ export function selectDefaultModelSelection(instances, preferred, context = {}) {
4
5
  if (preferred) {
5
6
  const instance = instances.find((candidate) => candidate.instanceId === preferred.instanceId);
6
7
  if (instance?.snapshot.state !== "available" ||
7
8
  instance.snapshot.authenticated === false ||
9
+ // An organisation that disallows the saved engine makes it unusable,
10
+ // exactly like an unavailable one: setup, never another provider.
11
+ context.refusal?.(instance) !== undefined ||
8
12
  (preferred.variant !== undefined && !instance.capabilities?.modelVariants) ||
9
13
  !(instance.models.default === preferred.model || instance.models.options.some((model) => model.id === preferred.model))) {
10
14
  return { instanceId: "", model: "" };
@@ -16,7 +20,29 @@ export function selectDefaultModelSelection(instances, preferred) {
16
20
  delete selection.effort;
17
21
  return selection;
18
22
  }
19
- const available = instances.filter((instance) => instance.snapshot.state === "available");
20
- const pick = available.find((instance) => instance.driverKind === "claudeAgent") ?? available[0];
23
+ // The organisation's policy is inert (no refusal) unless this desktop is enrolled.
24
+ const available = instances.filter((instance) => instance.snapshot.state === "available" && context.refusal?.(instance) === undefined);
25
+ const company = context.company;
26
+ let pick;
27
+ if (company && instances.some((instance) => company(instance.instanceId))) {
28
+ // Enrolled: an installed but signed-out personal Claude would send every
29
+ // new bot to setup although a Company model can run. Prefer instances that
30
+ // can run a turn now; a working personal engine still wins, so billing
31
+ // stays the person's explicit choice.
32
+ const ready = available.filter((instance) => instance.access === "custom" || instance.snapshot.authenticated !== false);
33
+ pick = claudeFirst(ready.filter((instance) => !company(instance.instanceId))) ?? claudeFirst(ready) ?? claudeFirst(available);
34
+ }
35
+ else {
36
+ pick = claudeFirst(available);
37
+ }
21
38
  return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" };
22
39
  }
40
+ /** Complete a new bot's selection with the workspace's new-bot effort. An
41
+ * explicit effort or model variant is the caller's choice and wins; an engine
42
+ * that does not offer the level keeps sending none rather than failing turn 1. */
43
+ export function withNewBotEffort(selection, effort, offered) {
44
+ if (!effort || selection.effort !== undefined || selection.variant !== undefined || !offered?.includes(effort)) {
45
+ return selection;
46
+ }
47
+ return { ...selection, effort };
48
+ }
@@ -16,7 +16,7 @@ import { writeFileAtomic } from "./atomic.js";
16
16
  import { getOrCreateChannel, mirrorExchange } from "./comms-visibility.js";
17
17
  import { DATA_DIR } from "./config.js";
18
18
  import { newId } from "./contracts.js";
19
- import { requestPeerApproval } from "./peer-approval.js";
19
+ import { peerApprovalFailure, requestPeerApproval } from "./peer-approval.js";
20
20
  import { canAccessTeam, peerAllowed } from "./peer-roster.js";
21
21
  /** Per source-thread queue. Persisted to delegations.json on every change
22
22
  * and reloaded at boot: a handoff queued right before a restart runs after
@@ -40,6 +40,13 @@ const RESULT_MAX_CHARS = 4_000;
40
40
  * to expire. This is not an uptime clock: sleep within a running process
41
41
  * still counts, and a non-expired restored window keeps its deadline. */
42
42
  export const DELEGATION_TTL_MS = 24 * 60 * 60 * 1000;
43
+ /** How long a handoff may wait on a target that never goes idle. The 24-hour
44
+ * window above bounds the rare case; the busy hold is the common one — a peer
45
+ * stays busy for a whole day and the delegating bot hears nothing back. Past
46
+ * this cap a still-blocked handoff expires with its own wording. Env-tunable
47
+ * so tests (and patient teams) can shrink or stretch it. */
48
+ const configuredBusyHoldMaxMs = Number(process.env.OMB_DELEGATION_BUSY_HOLD_MAX_MS);
49
+ export const DELEGATION_BUSY_HOLD_MAX_MS = Math.max(1_000, Number.isFinite(configuredBusyHoldMaxMs) && process.env.OMB_DELEGATION_BUSY_HOLD_MAX_MS !== "" ? configuredBusyHoldMaxMs : 2 * 60 * 60 * 1000);
43
50
  let receipts = [];
44
51
  function saveReceipts() {
45
52
  try {
@@ -63,6 +70,10 @@ export function recordDelegationReceipt(receipt) {
63
70
  };
64
71
  if (receipt.result !== undefined)
65
72
  bounded.result = receipt.result.slice(0, RESULT_MAX_CHARS);
73
+ if (receipt.approvalOutcome !== undefined) {
74
+ bounded.approvalOutcome = receipt.approvalOutcome;
75
+ bounded.approvalSource = peerApprovalFailure(receipt.approvalOutcome).approvalSource;
76
+ }
66
77
  receipts = [bounded, ...receipts.filter((existing) => existing.id !== bounded.id)]
67
78
  .filter((existing) => now - existing.finishedAt <= RECEIPT_MAX_AGE_MS)
68
79
  .slice(0, MAX_RECEIPTS);
@@ -90,9 +101,9 @@ export function threadsWaitingOn(toBotId) {
90
101
  }
91
102
  /** Mark a target's observed busy period as finished and return the source
92
103
  * threads that should be retried. A handoff waits until the target is free,
93
- * bounded only by the 24-hour expiry — this just clears the "parked on a
94
- * busy period" marker so the next drain re-evaluates it, rather than
95
- * counting or limiting retries.
104
+ * bounded by the busy-hold cap and the 24-hour expiry — this just clears the
105
+ * "parked on a busy period" marker so the next drain re-evaluates it, rather
106
+ * than counting or limiting retries.
96
107
  * `only` narrows the release: a bot that is still busy in one thread has
97
108
  * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
98
109
  * while its active-thread handoffs go on waiting for it to go idle. */
@@ -104,6 +115,7 @@ export function releaseDelegationsWaitingOn(toBotId, only) {
104
115
  if (item.toBotId !== toBotId || item.waitingOnBusy !== true || (only && !only(item)))
105
116
  continue;
106
117
  delete item.waitingOnBusy;
118
+ delete item.busySince;
107
119
  any = true;
108
120
  }
109
121
  if (any)
@@ -164,8 +176,14 @@ export function _loadPending() {
164
176
  };
165
177
  if (item.approvalAlreadyGranted === true)
166
178
  loaded.approvalAlreadyGranted = true;
167
- if (item.waitingOnBusy === true)
179
+ if (item.waitingOnBusy === true) {
168
180
  loaded.waitingOnBusy = true;
181
+ const currentHold = Number.isFinite(item.busySince) && item.busySince <= now &&
182
+ now - item.busySince < DELEGATION_BUSY_HOLD_MAX_MS;
183
+ loaded.busySince = currentHold ? item.busySince : now;
184
+ if (!currentHold)
185
+ backfilled = true;
186
+ }
169
187
  if (item.waitAnnounced === true || legacyAlreadyAnnounced)
170
188
  loaded.waitAnnounced = true;
171
189
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
@@ -212,6 +230,10 @@ export function _loadPending() {
212
230
  const receipt = { id, sourceThreadId, toBotId, toBotName, status, finishedAt: finishedAt };
213
231
  if (typeof result === "string")
214
232
  receipt.result = result;
233
+ if (candidate.approvalOutcome === "deny" || candidate.approvalOutcome === "expired" || candidate.approvalOutcome === "cancelled") {
234
+ receipt.approvalOutcome = candidate.approvalOutcome;
235
+ receipt.approvalSource = peerApprovalFailure(candidate.approvalOutcome).approvalSource;
236
+ }
215
237
  loaded.push(receipt);
216
238
  }
217
239
  receipts = loaded.slice(0, MAX_RECEIPTS);
@@ -405,41 +427,61 @@ function acknowledgeDelegation(threadId, itemId) {
405
427
  savePending();
406
428
  }
407
429
  const isExpired = (item, now) => now - item.queuedAt >= DELEGATION_TTL_MS;
430
+ /** Past the busy-hold cap — the tighter bound that fires while its target is
431
+ * still busy, long before the 24-hour window. */
432
+ const busyHoldExpired = (item, now) => item.busySince !== undefined && now - item.busySince >= DELEGATION_BUSY_HOLD_MAX_MS;
433
+ /** The busy-hold cap in chip-ready words ("2 hours", "90 minutes"). */
434
+ export function busyHoldCapText(maxMs = DELEGATION_BUSY_HOLD_MAX_MS) {
435
+ const minutes = Math.max(1, Math.round(maxMs / 60_000));
436
+ const amount = minutes % 60 === 0 ? minutes / 60 : minutes;
437
+ const unit = minutes % 60 === 0 ? "hour" : "minute";
438
+ return `${amount} ${unit}${amount === 1 ? "" : "s"}`;
439
+ }
408
440
  /** Record an expired handoff. The chip goes into the source thread only
409
441
  * while it still belongs to the bot that owns the handoff — a deleted
410
442
  * conversation gets the receipt and nothing else. */
411
443
  function expireDelegation(bus, sourceThreadId, item, ownerId) {
412
- const name = bus.store.bot(item.toBotId)?.name ?? item.toBotId;
444
+ const target = bus.store.bot(item.toBotId);
445
+ const name = target?.name ?? item.toBotId;
446
+ // A busy hold has its own words so a two-hour busy wait is never reported
447
+ // as a 24-hour one; every other expiry keeps the TTL wording.
448
+ const now = Date.now();
449
+ const busyHold = Boolean(target) && busyHoldExpired(item, now) && !isExpired(item, now);
413
450
  recordDelegationReceipt({
414
451
  id: item.id,
415
452
  sourceThreadId,
416
453
  toBotId: item.toBotId,
417
454
  toBotName: name,
418
455
  status: "expired",
419
- result: `@${name} was not free to take this for 24 hours`,
456
+ result: busyHold ? `@${name} was still busy after ${busyHoldCapText()}` : `@${name} was not free to take this for 24 hours`,
420
457
  });
421
458
  if (!sourceThreadBelongsToBot(bus.store, ownerId, sourceThreadId))
422
459
  return;
423
460
  bus.store.appendMessage(sourceThreadId, {
424
461
  role: "bot",
425
462
  kind: "activity",
426
- tool: { name: `Delegation to @${name} expired — not picked up within 24 hours`, ok: false },
463
+ tool: {
464
+ name: busyHold
465
+ ? `Delegation to @${name} expired — still busy after ${busyHoldCapText()}`
466
+ : `Delegation to @${name} expired — not picked up within 24 hours`,
467
+ ok: false,
468
+ },
427
469
  });
428
470
  }
429
- /** Past its 24 hours AND unable to be delivered right now — the same rule
471
+ /** Past a delivery bound AND unable to be delivered right now — the same rule
430
472
  * `processOne` applies, so the hourly sweep and a live drain never disagree
431
- * about which items are actually stuck. A target that was deleted counts as
432
- * "cannot take the turn": there is nothing to wait on, so such items still
433
- * expire even though there is no bot left to test busy/free against. */
473
+ * about which items are actually stuck. The busy-hold cap expires a handoff
474
+ * whose target has been busy the whole time; the 24-hour TTL catches the
475
+ * rest. A target that was deleted counts as "cannot take the turn": there is
476
+ * nothing to wait on, so such items still expire even though there is no bot
477
+ * left to test busy/free against. */
434
478
  function isDueForExpiry(bus, item, now) {
435
- if (!isExpired(item, now))
436
- return false;
437
479
  const target = bus.store.bot(item.toBotId);
438
480
  if (!target)
439
- return true;
440
- return !targetCanTakeTurn(bus, target, item);
481
+ return isExpired(item, now);
482
+ return !targetCanTakeTurn(bus, target, item) && (isExpired(item, now) || busyHoldExpired(item, now));
441
483
  }
442
- /** Expire every queued handoff past DELEGATION_TTL_MS that still cannot be
484
+ /** Expire every queued handoff past a delivery bound that still cannot be
443
485
  * delivered, wherever it waits. A drain already expires what it touches;
444
486
  * this covers the handoff nothing drains — a target that never settles
445
487
  * while its source sits idle. A thread mid-drain is skipped: that drain
@@ -549,13 +591,15 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
549
591
  if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
550
592
  return "settled";
551
593
  }
552
- // Past its 24 hours AND the target still cannot take the turn: this is the
553
- // bound on a busy wait. Use the same free/busy test as holdWhileTargetBusy;
554
- // an available target gets even an overdue item. Restart recovery renews
555
- // elapsed windows in _loadPending before any target becomes busy. Decide
556
- // before announcing a wait so an item cannot post both chips in one pass.
594
+ // Past a delivery bound AND the target still cannot take the turn: this is
595
+ // the bound on a busy wait — the busy-hold cap when the target has been
596
+ // busy the whole time, the 24-hour TTL otherwise. Use the same free/busy
597
+ // test as holdWhileTargetBusy; an available target gets even an overdue
598
+ // item. Restart recovery renews elapsed windows in _loadPending before any
599
+ // target becomes busy. Decide before announcing a wait so an item cannot
600
+ // post both chips in one pass.
557
601
  const canTakeTurn = targetCanTakeTurn(bus, target, item);
558
- if (!canTakeTurn && isExpired(item, Date.now())) {
602
+ if (!canTakeTurn && (isExpired(item, Date.now()) || busyHoldExpired(item, Date.now()))) {
559
603
  expireDelegation(bus, sourceThreadId, item, sender.id);
560
604
  return "settled";
561
605
  }
@@ -564,6 +608,7 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
564
608
  return held;
565
609
  if (item.waitingOnBusy) {
566
610
  delete item.waitingOnBusy;
611
+ delete item.busySince;
567
612
  savePending();
568
613
  }
569
614
  if (sender.approvePeerComms && !item.approvalAlreadyGranted) {
@@ -582,22 +627,26 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
582
627
  toBotName: target.name,
583
628
  status: "dropped",
584
629
  result: "the peer or source conversation no longer exists",
630
+ ...(verdict !== "allow" ? { approvalOutcome: verdict } : {}),
585
631
  });
586
632
  return "settled";
587
633
  }
588
634
  if (verdict !== "allow") {
635
+ const failure = peerApprovalFailure(verdict);
589
636
  recordDelegationReceipt({
590
637
  id: item.id,
591
638
  sourceThreadId,
592
639
  toBotId: target.id,
593
640
  toBotName: target.name,
594
- status: "denied",
595
- result: "the user denied this handoff",
641
+ status: verdict === "deny" ? "denied" : verdict,
642
+ approvalOutcome: verdict,
643
+ result: verdict === "deny" ? "the user denied this handoff" : failure.error,
596
644
  });
597
645
  bus.store.appendMessage(sourceThreadId, {
598
646
  role: "bot",
599
647
  kind: "activity",
600
- tool: { name: `Delegation to @${target.name} denied by user`, ok: false },
648
+ tool: { name: verdict === "deny" ? `Delegation to @${target.name} denied by user`
649
+ : `Delegation to @${target.name}: ${failure.error}`, ok: false },
601
650
  });
602
651
  return "settled";
603
652
  }
@@ -614,9 +663,10 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
614
663
  }
615
664
  // Approval may have waited for minutes (or, with approvalAlreadyGranted,
616
665
  // up to 24h since the original ask_bot approval) — recheck the same
617
- // free/busy-gated expiry as the pre-approval path before dispatching.
666
+ // free/busy-gated, busy-hold-capped expiry as the pre-approval path
667
+ // before dispatching.
618
668
  const canTakeTurnAfterApproval = targetCanTakeTurn(bus, current, item);
619
- if (!canTakeTurnAfterApproval && isExpired(item, Date.now())) {
669
+ if (!canTakeTurnAfterApproval && (isExpired(item, Date.now()) || busyHoldExpired(item, Date.now()))) {
620
670
  expireDelegation(bus, sourceThreadId, item, currentSender.id);
621
671
  return "settled";
622
672
  }
@@ -642,30 +692,33 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
642
692
  await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
643
693
  return "dispatched";
644
694
  }
645
- /** "Is the target free to take this handoff right now?" What "busy" means
646
- * depends on where the turn will run: a classic delegation lands in the
647
- * target's active thread, so it needs the bot to be idle; a fresh-thread
648
- * handoff needs only a free slot. This is the single free/busy test shared
649
- * by the expiry decision in `processOne` and the hold decision below, so
650
- * the two can never disagree about whether a handoff could have been
651
- * delivered right now. */
695
+ /** "Is the target free to take this handoff right now?" Both shapes ask for
696
+ * a free capacity slot, never whole-bot idleness: a classic delegation lands
697
+ * in the target's standing thread and applies the same admission startTurn
698
+ * uses for a direct turn there, while a fresh-thread handoff needs any free
699
+ * slot. This is the single free/busy test shared by the expiry decision in
700
+ * `processOne` and the hold decision below, so the two can never disagree
701
+ * about whether a handoff could have been delivered right now. */
652
702
  function targetCanTakeTurn(bus, target, item) {
653
703
  return item.targetThreadId
654
704
  ? (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
655
- : !target.busy;
656
- }
657
- /** A busy target holds the handoff. Neither counts busy periods — the only
658
- * bound is DELEGATION_TTL_MS, checked in processOne before this runs (using
659
- * the same `targetCanTakeTurn` test, passed in as `canTakeTurn` when the
660
- * caller already computed it so the two checks can't disagree). One waiting
661
- * chip per handoff, worded for what the target is actually doing. Returns
662
- * null when the target can take the turn now. */
705
+ : bus.canAdmitDirectTurn
706
+ ? bus.canAdmitDirectTurn(target.id, target.threadId)
707
+ : !target.busy;
708
+ }
709
+ /** A busy target holds the handoff. Neither counts busy periods — the bounds
710
+ * are the busy-hold cap and DELEGATION_TTL_MS, checked in processOne before
711
+ * this runs (using the same `targetCanTakeTurn` test, passed in as
712
+ * `canTakeTurn` when the caller already computed it so the two checks can't
713
+ * disagree). One waiting chip per handoff, worded for what the target is
714
+ * actually doing. Returns null when the target can take the turn now. */
663
715
  function holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn = targetCanTakeTurn(bus, target, item)) {
664
716
  if (canTakeTurn)
665
717
  return null;
666
718
  if (item.waitingOnBusy)
667
719
  return "requeued";
668
720
  item.waitingOnBusy = true;
721
+ item.busySince = Date.now();
669
722
  if (!item.waitAnnounced) {
670
723
  item.waitAnnounced = true;
671
724
  bus.store.appendMessage(sourceThreadId, {
@@ -724,7 +777,7 @@ function sourceThreadBelongsToBot(store, botId, threadId) {
724
777
  * have moved either bot, or narrowed the sender's peers, in between. */
725
778
  function dropIfUnreachable(bus, sender, target, sourceThreadId, item) {
726
779
  const sectionsDiffer = !canAccessTeam(sender, target.section);
727
- if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target.id))
780
+ if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target))
728
781
  return false;
729
782
  const reason = sectionsDiffer
730
783
  ? "bots now belong to different sections"