openmausbot 0.1.86 → 0.1.88

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 (216) hide show
  1. package/README.md +4 -3
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/companion/src/routes.js +8 -1
  63. package/dist-server/connector-proxy.js +105 -1
  64. package/dist-server/container-mcp.js +1032 -165
  65. package/dist-server/drivers/agents-proxy.js +338 -29
  66. package/dist-server/drivers/phone-proxy.js +33 -2
  67. package/dist-server/index.js +40590 -31811
  68. package/dist-server/local-computer-proxy.js +5 -3
  69. package/dist-server/local-computer.js +53 -17
  70. package/dist-server/mcp-gate.js +2 -0
  71. package/dist-server/mcp-server.js +5 -3
  72. package/dist-server/message-search.worker.js +52 -0
  73. package/dist-server/openmausbot.js +4457 -1399
  74. package/dist-server/pair-cli.js +4457 -1399
  75. package/dist-server/permission-proxy.js +29 -9
  76. package/dist-server/server/admission.js +81 -0
  77. package/dist-server/server/aside-queue.js +294 -0
  78. package/dist-server/server/attachments.js +98 -1
  79. package/dist-server/server/auto-approve.js +4 -3
  80. package/dist-server/server/auto-vm-claims.js +1 -0
  81. package/dist-server/server/bot-attachment.js +107 -0
  82. package/dist-server/server/bot-overview.js +19 -0
  83. package/dist-server/server/bot-package.js +18 -292
  84. package/dist-server/server/bot-profile.js +5 -17
  85. package/dist-server/server/box-create-idempotency.js +3 -3
  86. package/dist-server/server/box.js +57 -55
  87. package/dist-server/server/browser-proxy.js +3 -2
  88. package/dist-server/server/browser-runtime.js +37 -1
  89. package/dist-server/server/cli-api-setup.js +1 -0
  90. package/dist-server/server/cli.js +2 -2
  91. package/dist-server/server/command-allowlist.js +154 -0
  92. package/dist-server/server/composio.js +297 -1
  93. package/dist-server/server/computer-wait.js +22 -9
  94. package/dist-server/server/config.js +84 -12
  95. package/dist-server/server/connector-advertisement.js +165 -0
  96. package/dist-server/server/connector-proxy.js +14 -2
  97. package/dist-server/server/connector-verdict.js +168 -0
  98. package/dist-server/server/container-computer.js +61 -1
  99. package/dist-server/server/contracts.js +22 -0
  100. package/dist-server/server/default-model-selection.js +9 -0
  101. package/dist-server/server/delegations.js +85 -34
  102. package/dist-server/server/drivers/acp/core.js +80 -20
  103. package/dist-server/server/drivers/acp/cursor.js +3 -7
  104. package/dist-server/server/drivers/acp/droid.js +4 -7
  105. package/dist-server/server/drivers/acp/gemini.js +4 -4
  106. package/dist-server/server/drivers/acp/grok.js +4 -9
  107. package/dist-server/server/drivers/acp/hermes.js +3 -5
  108. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  109. package/dist-server/server/drivers/acp/qwen.js +9 -15
  110. package/dist-server/server/drivers/agents-call.js +179 -13
  111. package/dist-server/server/drivers/agents-catalog.js +134 -10
  112. package/dist-server/server/drivers/boxagent.js +160 -36
  113. package/dist-server/server/drivers/builtIn.js +2 -0
  114. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  115. package/dist-server/server/drivers/chat-images.js +54 -0
  116. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  117. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  118. package/dist-server/server/drivers/claude.js +38 -19
  119. package/dist-server/server/drivers/codex-catalog.js +14 -6
  120. package/dist-server/server/drivers/codex-identity.js +2 -1
  121. package/dist-server/server/drivers/codex-instructions.js +48 -6
  122. package/dist-server/server/drivers/codex-release.js +48 -0
  123. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  124. package/dist-server/server/drivers/codex.js +102 -55
  125. package/dist-server/server/drivers/mistral.js +115 -0
  126. package/dist-server/server/drivers/openai-chat.js +180 -33
  127. package/dist-server/server/drivers/openai-compat.js +1 -0
  128. package/dist-server/server/drivers/permission-command.js +35 -0
  129. package/dist-server/server/drivers/phone-proxy.js +47 -2
  130. package/dist-server/server/drivers/pi.js +306 -28
  131. package/dist-server/server/drivers/prompt-split.js +110 -0
  132. package/dist-server/server/drivers/session-idle.js +29 -0
  133. package/dist-server/server/env-path.js +15 -0
  134. package/dist-server/server/fleet.js +1 -1
  135. package/dist-server/server/group-thread-usage.js +103 -0
  136. package/dist-server/server/guarded-requests.js +2 -2
  137. package/dist-server/server/harness/registry.js +1 -0
  138. package/dist-server/server/harness-capabilities.js +46 -0
  139. package/dist-server/server/hosted-models.js +1 -1
  140. package/dist-server/server/index.js +2396 -505
  141. package/dist-server/server/local-computer.js +72 -23
  142. package/dist-server/server/managed-desktop.js +1 -1
  143. package/dist-server/server/mcp-bridge.js +6 -6
  144. package/dist-server/server/message-db.js +22 -42
  145. package/dist-server/server/message-file.js +9 -1
  146. package/dist-server/server/message-search-query.js +43 -0
  147. package/dist-server/server/message-search-worker.js +80 -0
  148. package/dist-server/server/message-search.worker.js +21 -0
  149. package/dist-server/server/model-requests.js +248 -0
  150. package/dist-server/server/new-bot-defaults.js +115 -0
  151. package/dist-server/server/options-card.js +21 -0
  152. package/dist-server/server/org-library.js +1065 -0
  153. package/dist-server/server/package-export.js +627 -39
  154. package/dist-server/server/package-import.js +458 -0
  155. package/dist-server/server/package-parts.js +87 -0
  156. package/dist-server/server/peer-approval.js +16 -7
  157. package/dist-server/server/peer-delivery.js +60 -0
  158. package/dist-server/server/peer-roster.js +67 -0
  159. package/dist-server/server/presets.js +492 -0
  160. package/dist-server/server/profile-requests.js +66 -39
  161. package/dist-server/server/profile-revision.js +9 -1
  162. package/dist-server/server/profile-versions.js +6 -3
  163. package/dist-server/server/provider-key-check.js +3 -2
  164. package/dist-server/server/published-teams.js +80 -0
  165. package/dist-server/server/room-handoffs.js +19 -3
  166. package/dist-server/server/routes/bot-presets.js +25 -0
  167. package/dist-server/server/routine-requests.js +35 -10
  168. package/dist-server/server/routines.js +41 -1
  169. package/dist-server/server/section-context.js +10 -0
  170. package/dist-server/server/skill-fetch.js +72 -14
  171. package/dist-server/server/skills.js +35 -61
  172. package/dist-server/server/steer-queue.js +3 -2
  173. package/dist-server/server/store.js +291 -17
  174. package/dist-server/server/surface.js +10 -4
  175. package/dist-server/server/system-prompt.js +38 -6
  176. package/dist-server/server/team-backup.js +6 -2
  177. package/dist-server/server/team-computers.js +10 -0
  178. package/dist-server/server/team-library.js +6 -2
  179. package/dist-server/server/team-manifest.js +4 -3
  180. package/dist-server/server/team-setup-requests.js +139 -27
  181. package/dist-server/server/thread-events.js +20 -2
  182. package/dist-server/server/tightening-requests.js +405 -0
  183. package/dist-server/server/tts/chatterbox.js +27 -5
  184. package/dist-server/server/turn-context.js +2 -0
  185. package/dist-server/server/turn-watchdog.js +12 -3
  186. package/dist-server/server/usage-ledger.js +19 -2
  187. package/dist-server/server/vps-computer.js +139 -51
  188. package/dist-server/server/vps-container-mcp.js +7 -0
  189. package/dist-server/server/vps-ssh.js +36 -7
  190. package/dist-server/server/workspace-backup-policy.js +12 -0
  191. package/dist-server/server/workspace-backup.js +42 -5
  192. package/dist-server/server/workspace-backup.worker.js +10 -0
  193. package/dist-server/server/workspace.js +1 -1
  194. package/dist-server/shared/approval-mode.js +1 -1
  195. package/dist-server/shared/ask-question.js +207 -12
  196. package/dist-server/shared/bot-profile.js +15 -0
  197. package/dist-server/shared/command-allowlist.js +1 -0
  198. package/dist-server/shared/credential-request.js +6 -2
  199. package/dist-server/shared/line-diff.js +23 -0
  200. package/dist-server/shared/model-request.js +1 -0
  201. package/dist-server/shared/options-card.js +51 -0
  202. package/dist-server/shared/package-format.js +967 -0
  203. package/dist-server/shared/profile-request.js +5 -4
  204. package/dist-server/shared/skill-md.js +64 -0
  205. package/dist-server/shared/team-backup.js +7 -0
  206. package/dist-server/shared/tightening-request.js +87 -0
  207. package/dist-server/shared/wire.js +4 -0
  208. package/dist-server/src/lib/composer-attachments.js +25 -3
  209. package/dist-server/vps-container-mcp.js +1132 -171
  210. package/dist-server/workspace-backup.worker.js +19474 -0
  211. package/enterprise/README.md +1 -1
  212. package/package.json +1 -1
  213. package/dist/assets/channel-BXFkgus9.js +0 -1
  214. package/dist/assets/index-BGgqlWPJ.css +0 -1
  215. package/dist/assets/index-BtHo8ZKl.js +0 -577
  216. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -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
  }
@@ -656,18 +706,19 @@ function targetCanTakeTurn(bus, target, item) {
656
706
  ? bus.canAdmitDirectTurn(target.id, target.threadId)
657
707
  : !target.busy;
658
708
  }
659
- /** A busy target holds the handoff. Neither counts busy periods — the only
660
- * bound is DELEGATION_TTL_MS, checked in processOne before this runs (using
661
- * the same `targetCanTakeTurn` test, passed in as `canTakeTurn` when the
662
- * caller already computed it so the two checks can't disagree). One waiting
663
- * chip per handoff, worded for what the target is actually doing. Returns
664
- * null when the target can take the turn now. */
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. */
665
715
  function holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn = targetCanTakeTurn(bus, target, item)) {
666
716
  if (canTakeTurn)
667
717
  return null;
668
718
  if (item.waitingOnBusy)
669
719
  return "requeued";
670
720
  item.waitingOnBusy = true;
721
+ item.busySince = Date.now();
671
722
  if (!item.waitAnnounced) {
672
723
  item.waitAnnounced = true;
673
724
  bus.store.appendMessage(sourceThreadId, {
@@ -31,6 +31,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
31
31
  import { createHash } from "node:crypto";
32
32
  import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
33
33
  import { decodeInjectId } from "../local-inject.js";
34
+ import { promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt } from "../prompt-split.js";
34
35
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
35
36
  /**
36
37
  * A `host::model` pick talks to a loopback server with its own key.
@@ -42,11 +43,14 @@ export function skipSubscriptionAuthForLocalInject(model) {
42
43
  import { newEventId, newId } from "../../contracts.js";
43
44
  import { augmentedPath } from "../../env-path.js";
44
45
  import { supportsApprovalMode } from "../../../shared/approval-mode.js";
46
+ import { parseAskQuestions, parseChoices, questionAnswersByQuestion } from "../../../shared/ask-question.js";
45
47
  import { appendNative } from "../native.js";
48
+ import { acpPermissionCommand, permissionLaunchCwd } from "../permission-command.js";
46
49
  import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
47
50
  import { extractMcpImages } from "../../mcp-tool-images.js";
48
51
  import { redactSecretsInText } from "../../redact.js";
49
52
  import { recoveryPromptFor } from "../../resume-recovery.js";
53
+ import { sessionIdlePolicy } from "../session-idle.js";
50
54
  /** ACP vendors put the actionable cause in error.data while keeping the
51
55
  * JSON-RPC message generic. Only surface known text fields, never a response
52
56
  * body/config dump, and redact before bounding the displayed diagnostic. */
@@ -77,6 +81,9 @@ const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
77
81
  const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
78
82
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
79
83
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
84
+ /** ACP agents may compact their own history without telling the client;
85
+ * re-send the full prompt after this many bare turns as a backstop. */
86
+ const ACP_PROMPT_RE_ANCHOR_TURNS = 8;
80
87
  // Read lazily (not at import) so a test can shorten the window. Unlike the
81
88
  // setup calls above, session/prompt legitimately streams for minutes, so a
82
89
  // wall-clock deadline would false-positive: this guard only trips when the
@@ -209,6 +216,24 @@ export function versionFromProbe(stdout, stderr) {
209
216
  const err = (stderr ?? "").trim().split(/\r\n|\n|\r/, 1)[0]?.trim() ?? "";
210
217
  return err || null;
211
218
  }
219
+ /** The first dotted numeric triple in a version banner, or null when there is
220
+ * none. A wrapper that prints its own banner first still parses: the triple
221
+ * is the version wherever it appears. */
222
+ export function parseVersionTriple(value) {
223
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(value);
224
+ if (!match)
225
+ return null;
226
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
227
+ }
228
+ /** Whether `installed` compares at or above `floor`, componentwise. Every
229
+ * version floor in the tree is a dotted triple. */
230
+ export function versionAtLeast(installed, floor) {
231
+ for (let i = 0; i < 3; i += 1) {
232
+ if (installed[i] !== floor[i])
233
+ return installed[i] > floor[i];
234
+ }
235
+ return true;
236
+ }
212
237
  export function createAcpDriver(support) {
213
238
  const DRIVER_KIND = support.driverKind;
214
239
  const SOURCE = support.nativeSource;
@@ -271,11 +296,7 @@ export function createAcpDriver(support) {
271
296
  // contract prompts the live session instead of paying the handshake
272
297
  // again. An idle session closes after SESSION_IDLE_MS of quiet.
273
298
  const sessions = new Map();
274
- const configuredIdleMinimum = Number(process.env.OMB_ACP_SESSION_IDLE_MIN_MS);
275
- const sessionIdleMinimum = Number.isFinite(configuredIdleMinimum) && configuredIdleMinimum > 0
276
- ? configuredIdleMinimum
277
- : 10_000;
278
- const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_ACP_SESSION_IDLE_MS) || 10 * 60_000);
299
+ const { idleMs: SESSION_IDLE_MS } = sessionIdlePolicy("ACP");
279
300
  const closeSession = (threadId, why) => {
280
301
  const session = sessions.get(threadId);
281
302
  if (!session || session.closing)
@@ -458,6 +479,7 @@ export function createAcpDriver(support) {
458
479
  * session.current, so a request that lands between turns is answered
459
480
  * (never brokered) instead of left hanging. */
460
481
  const openSession = (threadId, launch, argv, env, cwd, contractKey) => {
482
+ const commandCwd = permissionLaunchCwd(cwd);
461
483
  const child = spawnCli(launch.command, argv, {
462
484
  cwd,
463
485
  env,
@@ -660,16 +682,34 @@ export function createAcpDriver(support) {
660
682
  }
661
683
  }
662
684
  const tool = kind === "execute" ? "shell" : kind === "edit" ? "edit" : kind || "tool";
685
+ const isShellCommand = !isQuestion && kind === "execute" && !/^mcp(?:__|[.:])/i.test(String(toolCall.title ?? ""));
663
686
  const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
687
+ // One structured question beside the flat choices: the richer card
688
+ // renders from it while older clients keep answering through
689
+ // `choices`. Built once here so the emit and the answer path can
690
+ // never disagree. parseAskQuestions enforces the shared caps.
691
+ const questionChoices = isQuestion
692
+ ? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
693
+ : [];
694
+ const askQuestions = isQuestion && questionChoices.length
695
+ ? parseAskQuestions({ questions: [{ question: summary, options: questionChoices }] }) ?? undefined
696
+ : undefined;
664
697
  const requestId = newId();
665
698
  const finish = (behavior, source = "user", message, always) => {
666
699
  if (!current.asks.delete(requestId))
667
- return;
700
+ return "unavailable";
668
701
  clearTimeout(timer);
669
702
  const want = behavior === "allow" ? "allow" : "reject";
670
703
  const forSession = want === "allow" && always === true && !isQuestion && !current.controlsHost;
704
+ // A structured card replies in the Q:/A: block format; recover the
705
+ // picked label from it so exact-match keeps working. Flat clients
706
+ // send the bare label, which the single-question fallback inside
707
+ // questionAnswersByQuestion already returns unchanged.
708
+ const picked = askQuestions
709
+ ? questionAnswersByQuestion(message ?? "", askQuestions)[askQuestions[0].question] ?? message
710
+ : message;
671
711
  const named = isQuestion && behavior === "answer"
672
- ? options.filter((option) => option.optionId === message || option.name?.trim() === message)
712
+ ? options.filter((option) => option.optionId === picked || parseChoices([option.name], 1)?.[0] === picked)
673
713
  : [];
674
714
  const optionId = behavior === "cancel"
675
715
  ? null
@@ -700,6 +740,7 @@ export function createAcpDriver(support) {
700
740
  source: optionId ? source : "system",
701
741
  approvalScope: current.controlsHost ? "local-computer" : undefined,
702
742
  });
743
+ return !optionId ? "rejected" : isQuestion ? "answered" : behavior === "allow" ? "allowed-once" : "rejected";
703
744
  };
704
745
  const timer = setTimeout(() => {
705
746
  emit({ ...base(threadId, current.turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
@@ -714,9 +755,10 @@ export function createAcpDriver(support) {
714
755
  requestType: isQuestion ? "question" : "permission",
715
756
  tool,
716
757
  summary,
717
- choices: isQuestion
718
- ? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
719
- : undefined,
758
+ command: isShellCommand ? acpPermissionCommand(toolCall.rawInput, commandCwd) : undefined,
759
+ requiresExplicitApproval: isShellCommand && (toolCall.rawInput?.dangerouslyDisableSandbox === true || toolCall.rawInput?.sandbox_permissions === "require_escalated") || undefined,
760
+ choices: askQuestions?.[0]?.options.map(option => option.label) ?? (isQuestion ? questionChoices : undefined),
761
+ ...(askQuestions ? { questions: askQuestions } : {}),
720
762
  approvalScope: current.controlsHost ? "local-computer" : undefined,
721
763
  // the driver can honor a session-wide allow either way
722
764
  allowSession: !isQuestion && !current.controlsHost ? true : undefined,
@@ -1283,11 +1325,28 @@ export function createAcpDriver(support) {
1283
1325
  throw error;
1284
1326
  }
1285
1327
  emitSessionStarted();
1328
+ // The stable/volatile split: the full prompt rides only the turn
1329
+ // that establishes - or re-instructs, after a soul edit - this
1330
+ // native session. Later turns go through bare unless the volatile
1331
+ // half changed, so a memory edit neither appends a second copy of
1332
+ // the prompt to the agent's session history nor re-prices the
1333
+ // prefix its provider cached. Receipts are durable because the
1334
+ // native session outlives this process; an un-split turn (a direct
1335
+ // adapter call) keeps the legacy full-prompt shape.
1336
+ const halves = promptHalves(turn);
1337
+ let promptInput = promptTurn;
1338
+ let pendingSplitReceipt = null;
1339
+ if (halves.stable !== null) {
1340
+ const receiptKey = JSON.stringify([threadId, sessionId]);
1341
+ const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt(DRIVER_KIND, receiptKey), promptTurn.system, promptTurn.text, Boolean(turn.mentionTurn), ACP_PROMPT_RE_ANCHOR_TURNS);
1342
+ promptInput = { ...promptTurn, system: "", text: composed.text };
1343
+ pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt };
1344
+ }
1286
1345
  const text = support.buildPromptText
1287
- ? support.buildPromptText(promptTurn)
1288
- : promptTurn.system
1289
- ? `${promptTurn.system}\n\n${promptTurn.text}`
1290
- : promptTurn.text;
1346
+ ? support.buildPromptText(promptInput)
1347
+ : promptInput.system
1348
+ ? `${promptInput.system}\n\n${promptInput.text}`
1349
+ : promptInput.text;
1291
1350
  const imageBlocks = support.images === true && runtimeAcceptsImages
1292
1351
  ? await readAcpImageBlocks(turn.images ?? [])
1293
1352
  : [];
@@ -1301,6 +1360,12 @@ export function createAcpDriver(support) {
1301
1360
  const promptIdleMs = promptIdleTimeoutMs();
1302
1361
  const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, `${DRIVER_KIND} went fully silent ${Math.round(promptIdleMs / 1000)} s after the message and the turn was stopped. ` +
1303
1362
  "Raise OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS if this model legitimately takes longer to answer.");
1363
+ if (pendingSplitReceipt) {
1364
+ // session/prompt resolving is the acceptance boundary: a
1365
+ // rejected prompt leaves the receipt unwritten, so the next
1366
+ // turn redelivers what this one never received.
1367
+ writePromptSplitReceipt(DRIVER_KIND, pendingSplitReceipt.key, pendingSplitReceipt.receipt);
1368
+ }
1304
1369
  // opencode 1.18.18 reports usage at the result root; grok and
1305
1370
  // gemini put it under _meta. Read both rather than lose the count.
1306
1371
  const usage = result?.usage ?? result?._meta ?? {};
@@ -1407,12 +1472,7 @@ export function createAcpDriver(support) {
1407
1472
  const finish = turn?.asks.get(requestId);
1408
1473
  if (!finish)
1409
1474
  return "unavailable"; // settled, timed out, or turn gone
1410
- finish(decision.behavior, "user", decision.message, decision.always === true);
1411
- return decision.behavior === "allow"
1412
- ? "allowed-once"
1413
- : decision.behavior === "answer"
1414
- ? "answered"
1415
- : "rejected";
1475
+ return finish(decision.behavior, "user", decision.message, decision.always === true);
1416
1476
  },
1417
1477
  hasSession: (threadId) => active.has(threadId),
1418
1478
  stopAll: async () => {
@@ -1,3 +1,4 @@
1
+ import { titleCaseModelId } from "../../contracts.js";
1
2
  import { execCli } from "../../procs.js";
2
3
  import { createAcpDriver } from "./core.js";
3
4
  /** Translate an argv `--model` slug into the id this ACP session will accept.
@@ -86,13 +87,8 @@ function asRecord(value) {
86
87
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
87
88
  }
88
89
  function labelFor(id, explicit) {
89
- if (explicit?.trim())
90
- return explicit.trim();
91
- return id
92
- .split(/[-_./]+/g)
93
- .filter(Boolean)
94
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
95
- .join(" ");
90
+ const trimmed = explicit?.trim();
91
+ return trimmed || titleCaseModelId(id, /[-_./]+/);
96
92
  }
97
93
  function pushModel(options, seen, id, label, custom = false) {
98
94
  if (!SLUG.test(id) || seen.has(id))
@@ -16,8 +16,8 @@
16
16
  // options set over the wire (session/set_model, session/set_mode), which is
17
17
  // why both live in configureSession() below and NOT in spawnArgs.
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
- import { homedir } from "node:os";
20
19
  import { join } from "node:path";
20
+ import { userHome } from "../../env-path.js";
21
21
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
22
22
  import { createAcpDriver } from "./core.js";
23
23
  // FACTORY_HOME_OVERRIDE replaces the HOME the CLI resolves, NOT the data root:
@@ -34,7 +34,7 @@ import { createAcpDriver } from "./core.js";
34
34
  // out. Names read from the 0.196.0 binary.
35
35
  const AUTH_FILES = ["auth.v2.file", "auth.v2.loginkeychain", "auth.v2.keyring"];
36
36
  function authFilePaths(env) {
37
- const home = env.FACTORY_HOME_OVERRIDE || env.HOME || homedir();
37
+ const home = env.FACTORY_HOME_OVERRIDE || userHome(env);
38
38
  return AUTH_FILES.map((name) => join(home, ".factory", name));
39
39
  }
40
40
  const INJECT_ID_PREFIX = "custom:openmausbot-";
@@ -42,9 +42,6 @@ export function droidInjectId(host, model) {
42
42
  const safe = `${host}-${model}`.replace(/[^a-zA-Z0-9._+-]+/g, "-").replace(/-+/g, "-");
43
43
  return `${INJECT_ID_PREFIX}${safe}`;
44
44
  }
45
- function factoryHome(env) {
46
- return env.FACTORY_HOME_OVERRIDE || env.HOME || env.USERPROFILE || homedir();
47
- }
48
45
  /** Upsert a BYOK custom model so session/set_model can reach the local host. */
49
46
  export function ensureDroidInjectModel(modelId, env = process.env) {
50
47
  const inject = decodeInjectId(modelId);
@@ -54,7 +51,7 @@ export function ensureDroidInjectModel(modelId, env = process.env) {
54
51
  if (!host)
55
52
  return modelId;
56
53
  const id = droidInjectId(inject.host, inject.model);
57
- const dir = join(factoryHome(env), ".factory");
54
+ const dir = join(env.FACTORY_HOME_OVERRIDE || userHome(env), ".factory");
58
55
  mkdirSync(dir, { recursive: true });
59
56
  const path = join(dir, "settings.json");
60
57
  let settings = {};
@@ -105,7 +102,7 @@ export function applyDroidLocalAuthEnv(env, modelId) {
105
102
  env.FACTORY_API_KEY = "openmausbot-local";
106
103
  }
107
104
  function readSettings(env) {
108
- return JSON.parse(readFileSync(join(factoryHome(env), ".factory", "settings.json"), "utf8"));
105
+ return JSON.parse(readFileSync(join(env.FACTORY_HOME_OVERRIDE || userHome(env), ".factory", "settings.json"), "utf8"));
109
106
  }
110
107
  async function resolveModels(env) {
111
108
  let catalog = MODELS;
@@ -26,8 +26,8 @@
26
26
  // the ACP flag + auth method ids follow the published Gemini CLI ACP contract
27
27
  // and should be re-verified end-to-end once the CLI is present.
28
28
  import { existsSync, readFileSync } from "node:fs";
29
- import { homedir } from "node:os";
30
29
  import { join } from "node:path";
30
+ import { userHome } from "../../env-path.js";
31
31
  import { createAcpDriver } from "./core.js";
32
32
  // Prefer an explicit key method, then personal OAuth, then Vertex — but fall
33
33
  // back to whatever the CLI advertises so a new method id still works.
@@ -37,9 +37,9 @@ const AUTH_PREFERENCE = ["gemini-api-key", "oauth-personal", "vertex-ai"];
37
37
  * an enterprise licence may still redeem. A stale consumer credential —
38
38
  * the common case since 2026-06-18 — reads as not signed in, which is the
39
39
  * truthful answer for an engine that would fail its first turn anyway. */
40
- function liveOauthCredential() {
40
+ function liveOauthCredential(env) {
41
41
  try {
42
- const creds = JSON.parse(readFileSync(join(homedir(), ".gemini", "oauth_creds.json"), "utf8"));
42
+ const creds = JSON.parse(readFileSync(join(userHome(env), ".gemini", "oauth_creds.json"), "utf8"));
43
43
  if (typeof creds.access_token !== "string" || creds.access_token.trim().length === 0)
44
44
  return false;
45
45
  const hasRefresh = typeof creds.refresh_token === "string" && creds.refresh_token.trim().length > 0;
@@ -62,7 +62,7 @@ const nonBlank = (value) => Boolean(value?.trim());
62
62
  export function geminiIsAuthenticated(env) {
63
63
  return (nonBlank(env.GEMINI_API_KEY) ||
64
64
  nonBlank(env.GOOGLE_API_KEY) ||
65
- (existsSync(join(homedir(), ".gemini", "oauth_creds.json")) && liveOauthCredential()));
65
+ (existsSync(join(userHome(env), ".gemini", "oauth_creds.json")) && liveOauthCredential(env)));
66
66
  }
67
67
  /** Gemini CLI's approval modes, passed through: `--approval-mode auto_edit`
68
68
  * approves file edits, `--yolo` approves everything; Gemini has no
@@ -4,8 +4,8 @@
4
4
  // The generic protocol runtime lives in acp/core.ts; this file is only the
5
5
  // per-harness quirks. Verified against grok 1.0.0.
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
7
  import { join } from "node:path";
8
+ import { harnessHome } from "../../env-path.js";
9
9
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
10
10
  import { createAcpDriver } from "./core.js";
11
11
  export const STATIC_GROK_MODELS = {
@@ -17,11 +17,6 @@ export const STATIC_GROK_MODELS = {
17
17
  ],
18
18
  };
19
19
  const SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
20
- function grokHome(env) {
21
- if (env.GROK_HOME)
22
- return env.GROK_HOME;
23
- return join(env.HOME || env.USERPROFILE || homedir(), ".grok");
24
- }
25
20
  function unquote(raw) {
26
21
  const value = raw.trim();
27
22
  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
@@ -32,7 +27,7 @@ function unquote(raw) {
32
27
  /** Local slugs from ~/.grok/config.toml, plus the cloud defaults.
33
28
  * `grok -m <slug>` already accepts these; the picker just didn't list them. */
34
29
  export function readGrokModelCatalog(env = process.env) {
35
- const path = join(grokHome(env), "config.toml");
30
+ const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
36
31
  let text = "";
37
32
  try {
38
33
  text = readFileSync(path, "utf8");
@@ -114,7 +109,7 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
114
109
  const host = localHost(inject.host);
115
110
  if (!host)
116
111
  return modelId;
117
- const path = join(grokHome(env), "config.toml");
112
+ const path = join(env.GROK_HOME || harnessHome("grok", env), "config.toml");
118
113
  let text = "";
119
114
  try {
120
115
  text = readFileSync(path, "utf8");
@@ -258,7 +253,7 @@ const support = {
258
253
  // an unauthenticated CLI is a user action, not something to paper over.
259
254
  pickAuthMethod: (methods) => (methods.some((m) => m.id === "cached_token") ? "cached_token" : null),
260
255
  authFailure: "fail",
261
- isAuthenticated: () => existsSync(join(homedir(), ".grok", "auth.json")),
256
+ isAuthenticated: (env) => existsSync(join(harnessHome("grok", env), "auth.json")),
262
257
  // `--append-system-prompt`/`--rules` are accepted by the CLI but do NOT
263
258
  // reach the agent-stdio system prompt (verified against 1.0.0), so the
264
259
  // persona is prepended codex-style.
@@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { parse as parseYaml } from "yaml";
12
+ import { harnessHome } from "../../env-path.js";
12
13
  import { resolveCli } from "../../procs.js";
13
14
  import { decodeInjectId, hostApiKey, INJECT_SEP, localHost, mergeLocalInject } from "../local-inject.js";
14
15
  import { createAcpDriver } from "./core.js";
@@ -25,9 +26,6 @@ export function bindHermesScreenshotCompat(env, modelId) {
25
26
  env[HERMES_OPENMAUS_SCREENSHOT_COMPAT] = "1";
26
27
  env[HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL] = inject.model;
27
28
  }
28
- function hermesHome(env) {
29
- return env.HERMES_HOME || join(env.HOME || env.USERPROFILE || homedir(), ".hermes");
30
- }
31
29
  function quoteYaml(value) {
32
30
  if (/^[\w./:+-]+$/.test(value))
33
31
  return value;
@@ -69,7 +67,7 @@ export function ensureHermesInjectProvider(modelId, env = process.env) {
69
67
  const host = localHost(inject.host);
70
68
  if (!host)
71
69
  return modelId;
72
- const dir = hermesHome(env);
70
+ const dir = env.HERMES_HOME || harnessHome("hermes", env);
73
71
  mkdirSync(dir, { recursive: true });
74
72
  const path = join(dir, "config.yaml");
75
73
  let text = "";
@@ -199,7 +197,7 @@ function hermesConfigDefault(text) {
199
197
  * catalog exactly as it was for local-only setups.
200
198
  */
201
199
  export function hermesConfiguredModel(env = process.env) {
202
- const dir = hermesHome(env);
200
+ const dir = env.HERMES_HOME || harnessHome("hermes", env);
203
201
  let secrets = "";
204
202
  try {
205
203
  secrets = readFileSync(join(dir, ".env"), "utf8");