openmausbot 0.1.84 → 0.1.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  2. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  4. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  10. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  16. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  19. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  28. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  31. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  34. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  43. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  46. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  49. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  52. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  54. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  57. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  60. package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
  61. package/dist/assets/arc-DW7mrI4p.js +1 -0
  62. package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
  63. package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
  64. package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
  65. package/dist/assets/channel-BXFkgus9.js +1 -0
  66. package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
  67. package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
  68. package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
  69. package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
  70. package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
  71. package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
  72. package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
  73. package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
  74. package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
  75. package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
  76. package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
  77. package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
  78. package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
  79. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  80. package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
  81. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  82. package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
  83. package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
  84. package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
  85. package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
  86. package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
  87. package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
  88. package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
  89. package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
  90. package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
  91. package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
  92. package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
  93. package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
  94. package/dist/assets/graph-D8UmR1vK.js +1 -0
  95. package/dist/assets/index-BGgqlWPJ.css +1 -0
  96. package/dist/assets/index-BtHo8ZKl.js +577 -0
  97. package/dist/assets/{index-DNa2umw-.js → index-h9FFNwTu.js} +1 -1
  98. package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
  99. package/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
  101. package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
  102. package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
  103. package/dist/assets/linear-3su2TG93.js +1 -0
  104. package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
  105. package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
  106. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  107. package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
  108. package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
  109. package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
  110. package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
  111. package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
  112. package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
  113. package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
  114. package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
  115. package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
  116. package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
  117. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
  118. package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
  119. package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
  120. package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
  121. package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
  122. package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
  123. package/dist/index.html +2 -2
  124. package/dist-server/companion/src/routes.js +1 -0
  125. package/dist-server/container-mcp.js +231 -17
  126. package/dist-server/drivers/agents-proxy.js +16097 -1411
  127. package/dist-server/enterprise/LICENSE +32 -0
  128. package/dist-server/enterprise/server/index.js +6 -5
  129. package/dist-server/hooks/omb-hook.js +74 -0
  130. package/dist-server/index.js +12495 -7584
  131. package/dist-server/local-computer-proxy.js +83 -1
  132. package/dist-server/local-computer.js +14 -2
  133. package/dist-server/mcp-server.js +3 -1
  134. package/dist-server/openmausbot.js +5458 -4587
  135. package/dist-server/pair-cli.js +5458 -4587
  136. package/dist-server/proxy-paths.js +1 -0
  137. package/dist-server/server/admin-activity.js +410 -0
  138. package/dist-server/server/agent-tool-policy.js +1 -0
  139. package/dist-server/server/bot-package.js +2 -0
  140. package/dist-server/server/bot-visibility.js +462 -0
  141. package/dist-server/server/box.js +25 -5
  142. package/dist-server/server/browser-engine.js +87 -10
  143. package/dist-server/server/browser-live.js +7 -5
  144. package/dist-server/server/browser-runtime.js +66 -11
  145. package/dist-server/server/channel-queue.js +2 -0
  146. package/dist-server/server/checkpoints.js +62 -18
  147. package/dist-server/server/cli-prompts.js +26 -2
  148. package/dist-server/server/cli.js +79 -10
  149. package/dist-server/server/commands.js +27 -0
  150. package/dist-server/server/compaction-summary.js +78 -0
  151. package/dist-server/server/composio.js +49 -11
  152. package/dist-server/server/config.js +137 -14
  153. package/dist-server/server/context-budget.js +29 -0
  154. package/dist-server/server/context-rebuild.js +72 -0
  155. package/dist-server/server/decision-log.js +209 -48
  156. package/dist-server/server/default-model-selection.js +20 -3
  157. package/dist-server/server/delegations.js +11 -9
  158. package/dist-server/server/digest.js +130 -0
  159. package/dist-server/server/drivers/acp/core.js +637 -238
  160. package/dist-server/server/drivers/acp/grok.js +4 -3
  161. package/dist-server/server/drivers/agents-call.js +1123 -0
  162. package/dist-server/server/drivers/agents-catalog.js +659 -0
  163. package/dist-server/server/drivers/agents-client.js +28 -0
  164. package/dist-server/server/drivers/agents-proxy.js +18 -1683
  165. package/dist-server/server/drivers/agents-result.js +23 -0
  166. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  167. package/dist-server/server/drivers/claude.js +83 -16
  168. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  169. package/dist-server/server/drivers/codex.js +32 -4
  170. package/dist-server/server/drivers/grok.js +2 -1
  171. package/dist-server/server/drivers/openai-chat.js +66 -45
  172. package/dist-server/server/drivers/openai-compat.js +16 -8
  173. package/dist-server/server/enterprise.js +97 -16
  174. package/dist-server/server/external-runtime.js +83 -0
  175. package/dist-server/server/guarded-requests.js +60 -0
  176. package/dist-server/server/harness/http.js +61 -0
  177. package/dist-server/server/hooks/omb-hook.js +103 -0
  178. package/dist-server/server/hosted-models.js +151 -0
  179. package/dist-server/server/hosted-slack.js +11 -0
  180. package/dist-server/server/index.js +2810 -678
  181. package/dist-server/server/local-computer.js +21 -2
  182. package/dist-server/server/managed-desktop.js +79 -14
  183. package/dist-server/server/managed-policy.js +153 -0
  184. package/dist-server/server/mcp-bridge.js +10 -2
  185. package/dist-server/server/mcp-tool-images.js +37 -0
  186. package/dist-server/server/mcp-tool-schema.js +108 -0
  187. package/dist-server/server/message-db.js +108 -21
  188. package/dist-server/server/message-file.js +3 -2
  189. package/dist-server/server/model-context-window.js +7 -0
  190. package/dist-server/server/model-prices.js +121 -0
  191. package/dist-server/server/notify.js +10 -1
  192. package/dist-server/server/package-export.js +1 -0
  193. package/dist-server/server/peer-roster.js +17 -4
  194. package/dist-server/server/provider-icon.js +20 -0
  195. package/dist-server/server/proxy-paths.js +1 -0
  196. package/dist-server/server/recent-work.js +15 -0
  197. package/dist-server/server/request-auth.js +97 -0
  198. package/dist-server/server/room-handoffs.js +41 -13
  199. package/dist-server/server/routes/hosted-slack.js +20 -0
  200. package/dist-server/server/routes/table.js +14 -0
  201. package/dist-server/server/routine-requests.js +39 -1
  202. package/dist-server/server/routines.js +65 -9
  203. package/dist-server/server/shared-computer-control.js +31 -4
  204. package/dist-server/server/spend.js +111 -16
  205. package/dist-server/server/steer-queue.js +12 -1
  206. package/dist-server/server/store.js +384 -33
  207. package/dist-server/server/system-prompt.js +4 -4
  208. package/dist-server/server/team-backup.js +19 -2
  209. package/dist-server/server/thread-starters.js +53 -0
  210. package/dist-server/server/tool-results.js +72 -0
  211. package/dist-server/server/tts/grok.js +74 -0
  212. package/dist-server/server/tts/index.js +19 -1
  213. package/dist-server/server/usage-ledger.js +31 -8
  214. package/dist-server/server/webhooks.js +28 -0
  215. package/dist-server/server/workspace-backup-policy.js +9 -2
  216. package/dist-server/server/workspace-backup.js +7 -4
  217. package/dist-server/shared/approval-mode.js +7 -1
  218. package/dist-server/shared/digest.js +1 -0
  219. package/dist-server/shared/markdown-windows-paths.js +36 -0
  220. package/dist-server/shared/mention-boundary.js +15 -0
  221. package/dist-server/shared/provider-icon.js +110 -0
  222. package/dist-server/shared/routines.js +7 -1
  223. package/dist-server/shared/team-backup.js +5 -0
  224. package/dist-server/src/lib/composer-attachments.js +16 -0
  225. package/dist-server/vps-container-mcp.js +236 -22
  226. package/enterprise/README.md +33 -12
  227. package/enterprise/server/index.js +6 -5
  228. package/package.json +1 -1
  229. package/dist/assets/index-BbU5REzd.js +0 -310
  230. package/dist/assets/index-CLGfYlx_.css +0 -1
@@ -5,6 +5,17 @@
5
5
  // argv, auth method, model catalog, sign-in check) live in a small support
6
6
  // object. Adding a harness = write server/drivers/acp/<name>.ts.
7
7
  //
8
+ // One live agent process per (thread, spawn contract): the ACP handshake
9
+ // (initialize, authenticate) and the native session are established once,
10
+ // and later turns prompt the live session directly instead of paying the
11
+ // full handshake per message. The pool mirrors the Claude driver: a session
12
+ // closes after OMB_ACP_SESSION_IDLE_MS of quiet (default 10 minutes, floored
13
+ // by OMB_ACP_SESSION_IDLE_MIN_MS default 10s), when the spawn contract
14
+ // changes, when the child crashes, when an interrupt's cancel goes
15
+ // unanswered, and on stopAll/dispose. A resume cursor left by an earlier
16
+ // session resumes through session/load|resume when the process had to
17
+ // respawn.
18
+ //
8
19
  // ACP has no `turn/completed` notification: the `session/prompt` RPC *result*
9
20
  // is the completion signal (it carries stopReason + usage). Permission
10
21
  // requests arrive as server→client `session/request_permission` and surface
@@ -17,7 +28,8 @@
17
28
  import { homedir } from "node:os";
18
29
  import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
19
30
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
20
- import { PROVIDER_CREDENTIAL_ENV, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
31
+ import { createHash } from "node:crypto";
32
+ import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
21
33
  import { decodeInjectId } from "../local-inject.js";
22
34
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
23
35
  /**
@@ -32,11 +44,53 @@ import { augmentedPath } from "../../env-path.js";
32
44
  import { supportsApprovalMode } from "../../../shared/approval-mode.js";
33
45
  import { appendNative } from "../native.js";
34
46
  import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
47
+ import { extractMcpImages } from "../../mcp-tool-images.js";
48
+ import { redactSecretsInText } from "../../redact.js";
49
+ import { recoveryPromptFor } from "../../resume-recovery.js";
50
+ /** ACP vendors put the actionable cause in error.data while keeping the
51
+ * JSON-RPC message generic. Only surface known text fields, never a response
52
+ * body/config dump, and redact before bounding the displayed diagnostic. */
53
+ function acpRpcError(value, method) {
54
+ const message = typeof value?.message === "string" ? value.message : "ACP request failed";
55
+ const data = value?.data;
56
+ const detail = typeof data === "string" ? data
57
+ : typeof data?.details === "string" ? data.details
58
+ : typeof data?.message === "string" ? data.message
59
+ : typeof data?.error?.message === "string" ? data.error.message
60
+ : "";
61
+ const context = [
62
+ method,
63
+ typeof data?.service === "string" ? `service: ${data.service}` : "",
64
+ typeof data?.errorName === "string" ? data.errorName : "",
65
+ ].filter(Boolean).join(", ");
66
+ const diagnostic = `${detail && detail !== message ? `${message}: ${detail}` : message} (${context})`;
67
+ const error = new Error(redactSecretsInText(diagnostic).slice(0, 1500));
68
+ return Object.assign(error, { code: value?.code, data,
69
+ // -32603 is JSON-RPC's internal error. Invalid params, unsupported
70
+ // methods and auth refusals are user/configuration issues, not evidence
71
+ // of a broken process. Preserve their session instead of retrying them.
72
+ acpSessionFailure: value?.code === -32603,
73
+ });
74
+ }
35
75
  const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
36
76
  const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
37
77
  const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
38
78
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
39
79
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
80
+ // Read lazily (not at import) so a test can shorten the window. Unlike the
81
+ // setup calls above, session/prompt legitimately streams for minutes, so a
82
+ // wall-clock deadline would false-positive: this guard only trips when the
83
+ // child sends nothing at all for the whole window (a wedged OpenCode turn
84
+ // streams thought chunks, then goes silent forever and never resolves). 0
85
+ // disables the guard, restoring the pre-fix "hang until the user cancels"
86
+ // behavior.
87
+ const promptIdleTimeoutMs = () => {
88
+ const raw = process.env.OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS;
89
+ if (raw === undefined)
90
+ return 180_000;
91
+ const ms = Number(raw);
92
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
93
+ };
40
94
  const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
41
95
  function acpVariantOption(result) {
42
96
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
@@ -144,6 +198,17 @@ function sessionOperationKey(toolCall) {
144
198
  locations: toolCall?.locations,
145
199
  }));
146
200
  }
201
+ /** The banner an ACP CLI prints for `--version`. Hermes writes its whole banner
202
+ * to stderr with an empty stdout, so an stdout-only read reports a perfectly
203
+ * good install as "CLI not found". Prefer stdout; fall back to the first
204
+ * stderr line; null when both are empty. */
205
+ export function versionFromProbe(stdout, stderr) {
206
+ const out = (stdout ?? "").trim();
207
+ if (out)
208
+ return out;
209
+ const err = (stderr ?? "").trim().split(/\r\n|\n|\r/, 1)[0]?.trim() ?? "";
210
+ return err || null;
211
+ }
147
212
  export function createAcpDriver(support) {
148
213
  const DRIVER_KIND = support.driverKind;
149
214
  const SOURCE = support.nativeSource;
@@ -178,6 +243,8 @@ export function createAcpDriver(support) {
178
243
  if (!allowedCredentials.has(key))
179
244
  delete env[key];
180
245
  }
246
+ // The operator's own secrets are outside any driver's allowlist.
247
+ stripControlPlaneEnv(env);
181
248
  support.transformEnv?.(env, activeConfig, instanceId);
182
249
  return env;
183
250
  };
@@ -198,6 +265,48 @@ export function createAcpDriver(support) {
198
265
  await refreshModels();
199
266
  const listeners = new Set();
200
267
  const active = new Map();
268
+ // One live agent process per thread, kept across turns — the Claude
269
+ // driver's pool. The ACP handshake (initialize, authenticate) and the
270
+ // native session are established once; a later turn on the same spawn
271
+ // contract prompts the live session instead of paying the handshake
272
+ // again. An idle session closes after SESSION_IDLE_MS of quiet.
273
+ 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);
279
+ const closeSession = (threadId, why) => {
280
+ const session = sessions.get(threadId);
281
+ if (!session || session.closing)
282
+ return;
283
+ session.closing = true;
284
+ if (session.idleTimer)
285
+ clearTimeout(session.idleTimer);
286
+ appendNative(threadId, { dir: "out", source: SOURCE, msg: { close: why } });
287
+ // a new turn must never adopt a closing session
288
+ sessions.delete(threadId);
289
+ session.acp.close();
290
+ // stdin EOF asks the agent to exit; EOF is not a guaranteed exit
291
+ // signal for ACP agents, so insist after a grace period
292
+ try {
293
+ session.child.stdin.end();
294
+ }
295
+ catch { }
296
+ const kill = setTimeout(() => {
297
+ void killCliTree(session.child);
298
+ }, 5_000);
299
+ kill.unref?.();
300
+ };
301
+ const armIdle = (threadId) => {
302
+ const session = sessions.get(threadId);
303
+ if (!session)
304
+ return;
305
+ if (session.idleTimer)
306
+ clearTimeout(session.idleTimer);
307
+ session.idleTimer = setTimeout(() => closeSession(threadId, "idle"), SESSION_IDLE_MS);
308
+ session.idleTimer.unref?.();
309
+ };
201
310
  // "Always allow this session", remembered by the driver when the agent
202
311
  // offered no `allow_always` of its own: the exact operations (kind,
203
312
  // title, command, input, locations) a person allowed for the session,
@@ -297,128 +406,142 @@ export function createAcpDriver(support) {
297
406
  }
298
407
  return servers;
299
408
  };
300
- const sendTurn = async (turn) => {
301
- const { threadId } = turn;
302
- if (active.has(threadId))
303
- throw new Error("a turn is already running on this thread");
304
- // Provider-instance `fullAuto` predates per-bot approval levels. Every
305
- // harness turn now carries the bot's mode, so Ask/Auto must explicitly
306
- // put the native agent back into its interactive mode. Otherwise a
307
- // legacy Grok bypassPermissions / Cursor --force / Droid auto-high /
308
- // Antigravity yolo setting would silently outrank the selector. Calls
309
- // that omit approvalMode retain the old adapter-level behavior for
310
- // embedders and tests outside the harness.
311
- const turnConfig = turn.approvalMode === undefined
312
- ? config
313
- : { ...config, fullAuto: turn.approvalMode === "full" && supportsApprovalMode(DRIVER_KIND, "full") };
314
- const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
315
- if (controlsHost && turnConfig.fullAuto && turn.approvalMode !== "full") {
316
- throw new Error("local computer control requires interactive provider approvals");
317
- }
318
- const turnId = newId();
319
- const cwd = turn.cwd ?? turnConfig.workspace ?? homedir();
320
- const env = childEnv(turnConfig);
321
- if (support.requireAuthenticationBeforeSpawn
322
- && !skipSubscriptionAuthForLocalInject(turn.model)
323
- && !(await support.isAuthenticated(env, turnConfig, instanceId))) {
324
- emit({ ...base(threadId, turnId), type: "turn.started" });
325
- emit({ ...base(threadId, turnId), type: "runtime.error", message: support.loginNote, setup: true });
326
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "auth_required", cost: null });
327
- return { turnId };
328
- }
329
- const resolvedModel = support.resolveTurnModel?.(turn.model, env);
330
- support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
331
- const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
332
- ? { ...turn, model: resolvedModel }
333
- : turn;
334
- const mcpServers = acpMcpServers(turn);
335
- let launch;
336
- try {
337
- launch = support.resolveCommand
338
- ? await support.resolveCommand(env, turnConfig, instanceId)
339
- : { command: turnConfig.cli };
340
- }
341
- catch (error) {
342
- emit({ ...base(threadId, turnId), type: "turn.started" });
409
+ /** The one completion path for a turn: the prompt result, a crashed
410
+ * child, an unanswered cancel, or an rpc error the turn body throws.
411
+ * The child is NOT killed here — a clean settle leaves it pooled for
412
+ * the next turn on this contract. */
413
+ const settle = (threadId, session, ok, stopReason) => {
414
+ const current = session.current;
415
+ if (!current || current.state.settled)
416
+ return;
417
+ current.state.settled = true;
418
+ if (current.interruptTimer)
419
+ clearTimeout(current.interruptTimer);
420
+ for (const finish of current.asks.values())
421
+ finish("cancel", "system");
422
+ session.acp.failAll(new Error("turn settled"));
423
+ // detach before the final events: a listener that starts the next
424
+ // turn synchronously must find this session free
425
+ session.current = null;
426
+ active.delete(threadId);
427
+ current.flushAssistantText();
428
+ // `end_turn` with nothing to show for it — no reply, no image, no
429
+ // tool result — is a lost turn, not a success. An engine can report
430
+ // exactly that (a provider may cut a reasoning-only stream and
431
+ // still answer end_turn), and ok:true would end the thread quietly
432
+ // while the person's message went unanswered. Keep the completion,
433
+ // but report it as a failure so terminal chips, incidents and
434
+ // follow-ups see what happened.
435
+ let finalOk = ok;
436
+ let finalStopReason = stopReason;
437
+ if (finalOk && finalStopReason === null && !current.state.producedItem) {
438
+ finalOk = false;
439
+ finalStopReason = "empty_turn";
343
440
  emit({
344
- ...base(threadId, turnId),
441
+ ...base(threadId, current.turnId),
345
442
  type: "runtime.error",
346
- message: error instanceof Error ? error.message : String(error),
347
- setup: true,
443
+ message: `${DRIVER_KIND} ended the turn with no reply, image, or tool result`,
348
444
  });
349
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
350
- return { turnId };
351
445
  }
352
- const child = spawnCli(launch.command, [...(launch.args ?? []), ...support.spawnArgs(turnConfig, cliTurn)], {
446
+ emit({ ...base(threadId, current.turnId), type: "turn.completed", ok: finalOk, stopReason: finalStopReason, cost: null });
447
+ if (session.child.exitCode === null && !session.closing && !session.dead) {
448
+ armIdle(threadId);
449
+ }
450
+ else if (session.dead && sessions.get(threadId) === session) {
451
+ // a dead session is never pooled; the next turn respawns
452
+ sessions.delete(threadId);
453
+ }
454
+ };
455
+ /** Spawn the agent process and everything that lives for its whole
456
+ * lifetime: the wire connection, native logging, stderr tailing, and
457
+ * the server-request/update dispatch. Per-turn state arrives through
458
+ * session.current, so a request that lands between turns is answered
459
+ * (never brokered) instead of left hanging. */
460
+ const openSession = (threadId, launch, argv, env, cwd, contractKey) => {
461
+ const child = spawnCli(launch.command, argv, {
353
462
  cwd,
354
- env: launch.env ?? env,
463
+ env,
355
464
  stdio: ["pipe", "pipe", "pipe"],
356
465
  });
357
- const state = { settled: false, promptSent: false, text: "" };
358
- const asks = new Map();
359
466
  let nextId = 1;
360
- let sessionId = null;
361
- let sessionConfigResult = null;
362
- const modelOf = (result) => {
363
- const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
364
- return typeof option?.currentValue === "string" ? option.currentValue : null;
365
- };
366
- const receiveModelVariants = (result) => {
367
- sessionConfigResult = result;
368
- if (!support.modelVariants)
369
- return;
370
- const nativeModel = modelOf(result) ?? cliTurn.model;
371
- if (!nativeModel)
372
- return;
373
- const option = acpVariantOption(result);
374
- emit({
375
- ...base(threadId, turnId),
376
- type: "session.model-variants",
377
- model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
378
- variants: {
379
- options: option?.options ?? [],
380
- ...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
381
- },
382
- });
383
- };
384
- const requestedVariantOption = () => {
385
- if (!support.modelVariants)
386
- throw new Error(`${support.displayName} does not support model variants`);
387
- const option = acpVariantOption(sessionConfigResult);
388
- if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
389
- throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
390
- }
391
- return option;
392
- };
393
- let interruptTimer = null;
394
467
  const rpcPending = new Map();
395
468
  const send = (obj) => {
469
+ // A permission/file response resumes an agent that was waiting on us.
470
+ const message = obj;
471
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
472
+ for (const pending of rpcPending.values())
473
+ pending.armIdle();
474
+ }
396
475
  try {
397
476
  child.stdin.write(JSON.stringify(obj) + "\n");
398
477
  }
399
478
  catch { }
400
479
  appendNative(threadId, { dir: "out", source: SOURCE, msg: nativeLogMessage(obj) });
401
480
  };
402
- const request = (method, params, timeoutMs, receive) => new Promise((resolve, reject) => {
481
+ const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => new Promise((resolve, reject) => {
403
482
  const id = nextId++;
404
483
  let timer = null;
405
484
  if (timeoutMs) {
406
485
  timer = setTimeout(() => {
407
486
  rpcPending.delete(id);
408
- reject(new Error(`${method} timed out`));
487
+ reject(Object.assign(new Error(`${method} timed out`), { acpSessionFailure: true }));
409
488
  }, timeoutMs);
410
489
  timer.unref?.();
411
490
  }
491
+ // Idle watchdog: unlike a wall-clock timeout, the deadline restarts
492
+ // on every inbound line (see the stdout handler), so a long-lived
493
+ // streaming agent is never cut off — only one that has gone fully
494
+ // silent trips it.
495
+ let idleTimer = null;
496
+ const armIdle = () => {
497
+ if (!(idleMs && idleMs > 0))
498
+ return;
499
+ if (idleTimer)
500
+ clearTimeout(idleTimer);
501
+ idleTimer = setTimeout(() => {
502
+ // Waiting for a person is not an unresponsive agent.
503
+ if (session.current?.asks.size) {
504
+ armIdle();
505
+ return;
506
+ }
507
+ rpcPending.delete(id);
508
+ const error = new Error(idleMessage ?? `${method} stopped responding`);
509
+ Object.assign(error, { acpPromptStall: true });
510
+ reject(error);
511
+ }, idleMs);
512
+ idleTimer.unref?.();
513
+ };
514
+ armIdle();
412
515
  rpcPending.set(id, {
516
+ method,
413
517
  // Consume configuration in wire order: an update following this
414
518
  // response may arrive before the awaiting continuation resumes.
415
519
  resolve: (result) => { receive?.(result); resolve(result); },
416
520
  reject,
417
521
  timer,
522
+ get idleTimer() { return idleTimer; },
523
+ armIdle,
418
524
  });
419
525
  send({ jsonrpc: "2.0", id, method, params });
420
526
  });
421
- const stop = () => killCliTree(child);
527
+ const acp = {
528
+ send,
529
+ request,
530
+ failAll: (error) => {
531
+ for (const p of rpcPending.values()) {
532
+ if (p.timer)
533
+ clearTimeout(p.timer);
534
+ if (p.idleTimer)
535
+ clearTimeout(p.idleTimer);
536
+ p.reject(error);
537
+ }
538
+ rpcPending.clear();
539
+ },
540
+ close: () => {
541
+ acp.failAll(new Error("session closed"));
542
+ child.stdout.removeAllListeners("data");
543
+ },
544
+ };
422
545
  const resolveClientPath = async (requestPath) => {
423
546
  if (typeof requestPath !== "string" || !isAbsolute(requestPath)) {
424
547
  throw new Error("ACP file paths must be absolute.");
@@ -469,8 +592,8 @@ export function createAcpDriver(support) {
469
592
  send({ jsonrpc: "2.0", id: msg.id, result: { content } });
470
593
  return;
471
594
  }
472
- const line = Number.isInteger(params.line) && params.line > 0 ? params.line : 1;
473
- const limit = Number.isInteger(params.limit) && params.limit >= 0 ? params.limit : undefined;
595
+ const line = typeof params.line === "number" && Number.isInteger(params.line) && params.line > 0 ? params.line : 1;
596
+ const limit = typeof params.limit === "number" && Number.isInteger(params.limit) && params.limit >= 0 ? params.limit : undefined;
474
597
  const lines = content.split("\n");
475
598
  const start = line - 1;
476
599
  send({
@@ -491,35 +614,9 @@ export function createAcpDriver(support) {
491
614
  fail(error);
492
615
  }
493
616
  };
494
- /** Emit buffered assistant text as its own item, then clear it. */
495
- const flushAssistantText = () => {
496
- const text = state.text;
497
- state.text = "";
498
- if (!text.trim())
499
- return;
500
- emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
501
- };
502
- const settle = (ok, stopReason) => {
503
- if (state.settled)
504
- return;
505
- state.settled = true;
506
- if (interruptTimer)
507
- clearTimeout(interruptTimer);
508
- for (const finish of asks.values())
509
- finish("cancel", "system");
510
- for (const p of rpcPending.values()) {
511
- if (p.timer)
512
- clearTimeout(p.timer);
513
- p.reject(new Error("turn settled"));
514
- }
515
- rpcPending.clear();
516
- active.delete(threadId);
517
- flushAssistantText();
518
- emit({ ...base(threadId, turnId), type: "turn.completed", ok, stopReason, cost: null });
519
- stop(); // the agent process does not exit on its own
520
- };
521
- // server→client permission request → canonical request.opened
522
- const handleServerRequest = (msg) => {
617
+ // server→client permission request → canonical request.opened,
618
+ // answered fail-closed for the running turn
619
+ const handleServerRequest = (msg, current) => {
523
620
  if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
524
621
  void handleClientFileRequest(msg);
525
622
  return;
@@ -529,7 +626,7 @@ export function createAcpDriver(support) {
529
626
  return send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
530
627
  }
531
628
  const params = msg.params ?? {};
532
- flushAssistantText();
629
+ current.flushAssistantText();
533
630
  const options = Array.isArray(params.options) ? params.options : [];
534
631
  const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
535
632
  ?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
@@ -537,13 +634,13 @@ export function createAcpDriver(support) {
537
634
  const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
538
635
  const cancelled = { outcome: { outcome: "cancelled" } };
539
636
  const missing = (want) => emit({
540
- ...base(threadId, turnId),
637
+ ...base(threadId, current.turnId),
541
638
  type: "runtime.error",
542
639
  message: `${DRIVER_KIND} offered no "${want}" permission option — cancelling the request instead of guessing`,
543
640
  });
544
641
  const toolCall = params.toolCall ?? {};
545
642
  const isQuestion = String(toolCall.toolCallId ?? "").startsWith("interaction_");
546
- if (turnConfig.fullAuto && turn.approvalMode === undefined && !isQuestion) {
643
+ if (current.turnConfig.fullAuto && current.turn.approvalMode === undefined && !isQuestion) {
547
644
  const allow = optionFor("allow");
548
645
  if (!allow)
549
646
  missing("allow");
@@ -555,7 +652,7 @@ export function createAcpDriver(support) {
555
652
  }
556
653
  const kind = String(toolCall.kind ?? "");
557
654
  // an earlier "Always allow this session" on this exact operation
558
- const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
655
+ const operationKey = isQuestion || current.controlsHost ? null : sessionOperationKey(toolCall);
559
656
  if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
560
657
  const allow = optionFor("allow");
561
658
  if (allow) {
@@ -566,11 +663,11 @@ export function createAcpDriver(support) {
566
663
  const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
567
664
  const requestId = newId();
568
665
  const finish = (behavior, source = "user", message, always) => {
569
- if (!asks.delete(requestId))
666
+ if (!current.asks.delete(requestId))
570
667
  return;
571
668
  clearTimeout(timer);
572
669
  const want = behavior === "allow" ? "allow" : "reject";
573
- const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
670
+ const forSession = want === "allow" && always === true && !isQuestion && !current.controlsHost;
574
671
  const named = isQuestion && behavior === "answer"
575
672
  ? options.filter((option) => option.optionId === message || option.name?.trim() === message)
576
673
  : [];
@@ -596,22 +693,22 @@ export function createAcpDriver(support) {
596
693
  result: optionId ? { outcome: { outcome: "selected", optionId } } : cancelled,
597
694
  });
598
695
  emit({
599
- ...base(threadId, turnId),
696
+ ...base(threadId, current.turnId),
600
697
  type: "request.resolved",
601
698
  requestId,
602
699
  behavior: optionId && isQuestion ? "answer" : optionId && behavior === "allow" ? "allow" : "deny",
603
700
  source: optionId ? source : "system",
604
- approvalScope: controlsHost ? "local-computer" : undefined,
701
+ approvalScope: current.controlsHost ? "local-computer" : undefined,
605
702
  });
606
703
  };
607
704
  const timer = setTimeout(() => {
608
- emit({ ...base(threadId, turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
705
+ emit({ ...base(threadId, current.turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
609
706
  finish("deny", "timeout");
610
707
  }, 15 * 60_000);
611
708
  timer.unref?.();
612
- asks.set(requestId, finish);
709
+ current.asks.set(requestId, finish);
613
710
  emit({
614
- ...base(threadId, turnId),
711
+ ...base(threadId, current.turnId),
615
712
  type: "request.opened",
616
713
  requestId,
617
714
  requestType: isQuestion ? "question" : "permission",
@@ -620,9 +717,9 @@ export function createAcpDriver(support) {
620
717
  choices: isQuestion
621
718
  ? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
622
719
  : undefined,
623
- approvalScope: controlsHost ? "local-computer" : undefined,
720
+ approvalScope: current.controlsHost ? "local-computer" : undefined,
624
721
  // the driver can honor a session-wide allow either way
625
- allowSession: !isQuestion && !controlsHost ? true : undefined,
722
+ allowSession: !isQuestion && !current.controlsHost ? true : undefined,
626
723
  });
627
724
  };
628
725
  const handleNotification = (msg) => {
@@ -633,12 +730,13 @@ export function createAcpDriver(support) {
633
730
  const p = msg.params ?? {};
634
731
  if (p._meta?.isReplay === true)
635
732
  return;
733
+ const current = session.current;
636
734
  if (support.modelVariants && p.update?.sessionUpdate === "config_option_update") {
637
- if (!state.settled && sessionId && p.sessionId === sessionId)
638
- receiveModelVariants(p.update);
735
+ if (current && !current.state.settled && session.sessionId && p.sessionId === session.sessionId)
736
+ current.receiveModelVariants(p.update);
639
737
  return;
640
738
  }
641
- if (!state.promptSent)
739
+ if (!current || !current.state.promptSent)
642
740
  return;
643
741
  const u = p.update ?? {};
644
742
  switch (u.sessionUpdate) {
@@ -646,9 +744,10 @@ export function createAcpDriver(support) {
646
744
  const content = u.content;
647
745
  const delta = content?.text;
648
746
  if (content?.type === "image" && typeof content.data === "string" && content.data) {
649
- flushAssistantText();
747
+ current.flushAssistantText();
748
+ current.state.producedItem = true;
650
749
  emit({
651
- ...base(threadId, turnId),
750
+ ...base(threadId, current.turnId),
652
751
  type: "item.completed",
653
752
  itemType: "assistant_image",
654
753
  data: content.data,
@@ -656,22 +755,22 @@ export function createAcpDriver(support) {
656
755
  });
657
756
  }
658
757
  else if (typeof delta === "string" && delta) {
659
- state.text += delta;
660
- emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta });
758
+ current.state.text += delta;
759
+ emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "assistant_text", delta });
661
760
  }
662
761
  break;
663
762
  }
664
763
  case "agent_thought_chunk": {
665
764
  const delta = u.content?.text;
666
765
  if (typeof delta === "string" && delta) {
667
- emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "reasoning_text", delta });
766
+ emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "reasoning_text", delta });
668
767
  }
669
768
  break;
670
769
  }
671
770
  case "tool_call": {
672
- flushAssistantText();
771
+ current.flushAssistantText();
673
772
  emit({
674
- ...base(threadId, turnId),
773
+ ...base(threadId, current.turnId),
675
774
  type: "item.started",
676
775
  itemType: "tool",
677
776
  itemId: u.toolCallId,
@@ -683,19 +782,40 @@ export function createAcpDriver(support) {
683
782
  }
684
783
  case "tool_call_update": {
685
784
  if (u.status === "completed" || u.status === "failed") {
785
+ current.state.producedItem = true;
686
786
  emit({
687
- ...base(threadId, turnId),
787
+ ...base(threadId, current.turnId),
688
788
  type: "item.completed",
689
789
  itemType: "tool",
690
790
  itemId: u.toolCallId,
691
791
  ok: u.status !== "failed",
692
792
  output: toolDetailPreview(u.rawOutput ?? u.content),
693
793
  });
794
+ for (const img of extractMcpImages(u.content ?? u.rawOutput)) {
795
+ emit({ ...base(threadId, current.turnId), type: "item.completed", itemType: "assistant_image", data: img.data });
796
+ }
694
797
  }
695
798
  break;
696
799
  }
697
800
  }
698
801
  };
802
+ const session = {
803
+ child,
804
+ acp,
805
+ launch,
806
+ cwd,
807
+ contractKey,
808
+ sessionKey: null,
809
+ sessionId: null,
810
+ sessionConfigResult: null,
811
+ initResult: null,
812
+ authenticated: false,
813
+ idleTimer: null,
814
+ closing: false,
815
+ dead: false,
816
+ stderr: "",
817
+ current: null,
818
+ };
699
819
  let buf = "";
700
820
  // decode as UTF-8 across chunk boundaries — a raw `buf += chunk` splits
701
821
  // multibyte characters that straddle two reads and corrupts the text
@@ -716,16 +836,20 @@ export function createAcpDriver(support) {
716
836
  continue;
717
837
  }
718
838
  appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
839
+ // Inbound traffic proves the child is alive and making progress,
840
+ // so every idle deadline restarts; only total silence trips it.
841
+ for (const p of rpcPending.values())
842
+ p.armIdle();
719
843
  if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
720
844
  const pend = rpcPending.get(msg.id);
721
845
  if (pend) {
722
846
  rpcPending.delete(msg.id);
723
847
  if (pend.timer)
724
848
  clearTimeout(pend.timer);
849
+ if (pend.idleTimer)
850
+ clearTimeout(pend.idleTimer);
725
851
  if (msg.error) {
726
- const error = new Error(msg.error.message ?? JSON.stringify(msg.error));
727
- Object.assign(error, { code: msg.error.code, data: msg.error.data });
728
- pend.reject(error);
852
+ pend.reject(acpRpcError(msg.error, pend.method));
729
853
  }
730
854
  else {
731
855
  pend.resolve(msg.result);
@@ -733,112 +857,373 @@ export function createAcpDriver(support) {
733
857
  }
734
858
  }
735
859
  else if (msg.id !== undefined && msg.method) {
736
- handleServerRequest(msg);
860
+ const current = session.current;
861
+ if (!current) {
862
+ // between turns nothing is brokered: cancel a permission
863
+ // request and refuse anything else — the agent must never
864
+ // block on an unanswered request
865
+ send(msg.method === "session/request_permission"
866
+ ? { jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }
867
+ : { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
868
+ }
869
+ else {
870
+ handleServerRequest(msg, current);
871
+ }
737
872
  }
738
873
  else if (msg.method) {
739
874
  handleNotification(msg);
740
875
  }
741
876
  }
742
877
  });
743
- let stderr = "";
744
878
  child.stderr.on("data", (c) => {
745
879
  if (!support.redactStderr)
746
- stderr += c;
747
- if (stderr.length > 8192)
748
- stderr = stderr.slice(-8192);
880
+ session.stderr += c;
881
+ if (session.stderr.length > 8192)
882
+ session.stderr = session.stderr.slice(-8192);
749
883
  });
750
884
  child.on("error", (e) => {
751
- emit({ ...base(threadId, turnId), type: "runtime.error", ...describeSpawnFailure(e, launch.command) });
752
- settle(false, "spawn_error");
885
+ session.dead = true;
886
+ const current = session.current;
887
+ if (!current)
888
+ return;
889
+ emit({ ...base(threadId, current.turnId), type: "runtime.error", ...describeSpawnFailure(e, launch.command) });
890
+ settle(threadId, session, false, "spawn_error");
753
891
  });
754
892
  child.on("close", (code) => {
755
- if (!state.settled) {
893
+ session.dead = true;
894
+ const current = session.current;
895
+ if (current) {
756
896
  emit({
757
- ...base(threadId, turnId),
897
+ ...base(threadId, current.turnId),
758
898
  type: "runtime.error",
759
- message: `${DRIVER_KIND} exited ${code} before the prompt result${stderr ? `: ${stderr.trim().slice(-300)}` : ""}`,
899
+ message: `${DRIVER_KIND} exited ${code} before the prompt result${session.stderr ? `: ${session.stderr.trim().slice(-300)}` : ""}`,
760
900
  });
761
- settle(false, "exit_before_result");
901
+ settle(threadId, session, false, "exit_before_result");
902
+ }
903
+ else if (sessions.get(threadId) === session) {
904
+ // drop the record between turns; a later turn respawns. The
905
+ // identity check keeps an old child's exit from unlinking a
906
+ // session that already replaced this one.
907
+ sessions.delete(threadId);
762
908
  }
763
909
  });
910
+ return session;
911
+ };
912
+ const sendTurn = async (turn) => {
913
+ const { threadId } = turn;
914
+ if (active.has(threadId))
915
+ throw new Error("a turn is already running on this thread");
916
+ // Provider-instance `fullAuto` predates per-bot approval levels. Every
917
+ // harness turn now carries the bot's mode, so Ask/Auto must explicitly
918
+ // put the native agent back into its interactive mode. Otherwise a
919
+ // legacy Grok bypassPermissions / Cursor --force / Droid auto-high /
920
+ // Antigravity yolo setting would silently outrank the selector. Calls
921
+ // that omit approvalMode retain the old adapter-level behavior for
922
+ // embedders and tests outside the harness.
923
+ const turnConfig = turn.approvalMode === undefined
924
+ ? config
925
+ : { ...config, fullAuto: turn.approvalMode === "full" && supportsApprovalMode(DRIVER_KIND, "full") };
926
+ const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
927
+ if (controlsHost && turnConfig.fullAuto && turn.approvalMode !== "full") {
928
+ throw new Error("local computer control requires interactive provider approvals");
929
+ }
930
+ const turnId = newId();
931
+ const cwd = turn.cwd ?? turnConfig.workspace ?? homedir();
932
+ const env = childEnv(turnConfig);
933
+ if (support.requireAuthenticationBeforeSpawn
934
+ && !skipSubscriptionAuthForLocalInject(turn.model)
935
+ && !(await support.isAuthenticated(env, turnConfig, instanceId))) {
936
+ emit({ ...base(threadId, turnId), type: "turn.started" });
937
+ emit({ ...base(threadId, turnId), type: "runtime.error", message: support.loginNote, setup: true });
938
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "auth_required", cost: null });
939
+ return { turnId };
940
+ }
941
+ const resolvedModel = support.resolveTurnModel?.(turn.model, env);
942
+ support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
943
+ const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
944
+ ? { ...turn, model: resolvedModel }
945
+ : turn;
946
+ const mcpServers = acpMcpServers(turn);
947
+ let launch;
948
+ try {
949
+ launch = support.resolveCommand
950
+ ? await support.resolveCommand(env, turnConfig, instanceId)
951
+ : { command: turnConfig.cli };
952
+ }
953
+ catch (error) {
954
+ emit({ ...base(threadId, turnId), type: "turn.started" });
955
+ emit({
956
+ ...base(threadId, turnId),
957
+ type: "runtime.error",
958
+ message: error instanceof Error ? error.message : String(error),
959
+ setup: true,
960
+ });
961
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
962
+ return { turnId };
963
+ }
964
+ // The spawn contract: everything that changes what process a turn
965
+ // gets. The model rides argv where a support passes -m, and a
966
+ // selectModel support re-applies it over the wire on the live
967
+ // session, so the model is not a separate axis; fullAuto covers the
968
+ // transformEnv-policy supports (opencode). mcpServers are session
969
+ // establishment inputs — they ride session/new and session/load over
970
+ // the wire — and the harness mints fresh integration bearer tokens
971
+ // every turn, so they must not respawn the process; a change instead
972
+ // re-establishes the session below (see sessionKey).
973
+ // The env the spawned child actually receives is part of the
974
+ // contract too, and arrives hashed as envFingerprint for the same
975
+ // reason.
976
+ const spawnArgs = support.spawnArgs(turnConfig, cliTurn);
977
+ const spawnEnv = launch.env ?? env;
978
+ // Env is part of the spawn contract: a turn that changes auth env
979
+ // (FACTORY_API_KEY placeholder, a fresh login file) must not keep
980
+ // riding a child spawned under the old env. Hash it so secrets
981
+ // never sit in the key itself.
982
+ const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
983
+ const contractKey = JSON.stringify([launch.command, launch.args ?? [], spawnArgs, cwd, turnConfig.fullAuto === true, envFingerprint]);
984
+ const sessionKey = JSON.stringify(mcpServers);
985
+ if (turn.sessionReset) {
986
+ closeSession(threadId, "reset");
987
+ sessionAllows.delete(threadId);
988
+ }
989
+ const pooled = sessions.get(threadId);
990
+ let session;
991
+ if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey) {
992
+ // adoption cancels the idle countdown — a running turn is not quiet
993
+ if (pooled.idleTimer)
994
+ clearTimeout(pooled.idleTimer);
995
+ pooled.idleTimer = null;
996
+ session = pooled;
997
+ }
998
+ else {
999
+ if (pooled) {
1000
+ // a dead child already exited — just drop the record; a live one
1001
+ // gets the full close (contract changed)
1002
+ if (pooled.dead)
1003
+ sessions.delete(threadId);
1004
+ else
1005
+ closeSession(threadId, "contract");
1006
+ }
1007
+ session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
1008
+ sessions.set(threadId, session);
1009
+ }
1010
+ // `session` rebinds mid-turn: when the establishment retry below
1011
+ // respawns the child, every wire call must reach the live record, so
1012
+ // nothing captures the connection off it.
1013
+ const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
1014
+ const state = { settled: false, promptSent: false, text: "", producedItem: false };
1015
+ const asks = new Map();
1016
+ const modelOf = (result) => {
1017
+ const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
1018
+ return typeof option?.currentValue === "string" ? option.currentValue : null;
1019
+ };
1020
+ const receiveModelVariants = (result) => {
1021
+ session.sessionConfigResult = result;
1022
+ if (!support.modelVariants)
1023
+ return;
1024
+ const nativeModel = modelOf(result) ?? cliTurn.model;
1025
+ if (!nativeModel)
1026
+ return;
1027
+ const option = acpVariantOption(result);
1028
+ emit({
1029
+ ...base(threadId, turnId),
1030
+ type: "session.model-variants",
1031
+ model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
1032
+ variants: {
1033
+ options: option?.options ?? [],
1034
+ ...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
1035
+ },
1036
+ });
1037
+ };
1038
+ const requestedVariantOption = () => {
1039
+ if (!support.modelVariants)
1040
+ throw new Error(`${support.displayName} does not support model variants`);
1041
+ const option = acpVariantOption(session.sessionConfigResult);
1042
+ if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
1043
+ throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
1044
+ }
1045
+ return option;
1046
+ };
1047
+ /** Emit buffered assistant text as its own item, then clear it. */
1048
+ const flushAssistantText = () => {
1049
+ const text = state.text;
1050
+ state.text = "";
1051
+ if (!text.trim())
1052
+ return;
1053
+ state.producedItem = true;
1054
+ emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
1055
+ };
1056
+ const current = {
1057
+ turnId,
1058
+ turn: cliTurn,
1059
+ turnConfig,
1060
+ controlsHost,
1061
+ state,
1062
+ asks,
1063
+ interruptTimer: null,
1064
+ flushAssistantText,
1065
+ receiveModelVariants,
1066
+ };
764
1067
  const interrupt = () => {
765
- if (sessionId)
766
- send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
767
- else
768
- stop();
769
- if (interruptTimer)
770
- clearTimeout(interruptTimer);
771
- interruptTimer = setTimeout(() => settle(true, "cancelled"), 5_000);
772
- interruptTimer.unref?.();
1068
+ if (session.sessionId) {
1069
+ session.acp.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: session.sessionId } });
1070
+ if (current.interruptTimer)
1071
+ clearTimeout(current.interruptTimer);
1072
+ current.interruptTimer = setTimeout(() => {
1073
+ // an agent that ignores session/cancel must not stay pooled
1074
+ closeSession(threadId, "cancel-timeout");
1075
+ settle(threadId, session, true, "cancelled");
1076
+ }, 5_000);
1077
+ current.interruptTimer.unref?.();
1078
+ }
1079
+ else {
1080
+ // no native session to cancel — the close handler settles
1081
+ closeSession(threadId, "stop");
1082
+ }
773
1083
  };
774
- active.set(threadId, { stop, interrupt, turnId, asks });
1084
+ active.set(threadId, { stop: () => closeSession(threadId, "stop"), interrupt, turnId, asks });
775
1085
  emit({ ...base(threadId, turnId), type: "turn.started" });
1086
+ session.current = current;
776
1087
  (async () => {
777
1088
  try {
778
- const init = await request("initialize", {
779
- protocolVersion: 1,
780
- clientInfo: { name: "openmausbot", version: "0.0.0" },
781
- clientCapabilities: {
782
- fs: {
783
- readTextFile: support.clientFileSystem === true,
784
- writeTextFile: support.clientFileSystem === true,
785
- },
786
- terminal: false,
787
- },
788
- }, INIT_TIMEOUT);
789
- const methods = Array.isArray(init?.authMethods) ? init.authMethods : [];
790
- const methodId = support.pickAuthMethod(methods);
791
- if (!skipSubscriptionAuthForLocalInject(turn.model)) {
792
- if (methodId) {
1089
+ // The handshake is paid once per process, not once per turn. It
1090
+ // is a function so the establishment retry below can pay it
1091
+ // again on a replacement child.
1092
+ // Returns whether this runtime accepts image prompts, for the
1093
+ // prompt phase below.
1094
+ const handshake = async () => {
1095
+ if (!session.initResult) {
1096
+ session.initResult = await request("initialize", {
1097
+ protocolVersion: 1,
1098
+ clientInfo: { name: "openmausbot", version: "0.0.0" },
1099
+ clientCapabilities: {
1100
+ fs: {
1101
+ readTextFile: support.clientFileSystem === true,
1102
+ writeTextFile: support.clientFileSystem === true,
1103
+ },
1104
+ terminal: false,
1105
+ },
1106
+ }, INIT_TIMEOUT);
1107
+ }
1108
+ // authenticate is once per process; a turn that skips
1109
+ // subscription auth neither checks nor marks the flag
1110
+ if (!skipSubscriptionAuthForLocalInject(turn.model) && !session.authenticated) {
1111
+ const methods = Array.isArray(session.initResult?.authMethods)
1112
+ ? session.initResult.authMethods
1113
+ : [];
1114
+ const methodId = support.pickAuthMethod(methods);
1115
+ if (methodId) {
1116
+ try {
1117
+ await request("authenticate", { methodId }, INIT_TIMEOUT);
1118
+ session.authenticated = true;
1119
+ }
1120
+ catch {
1121
+ if (support.authFailure === "fail")
1122
+ throw new Error(support.loginNote);
1123
+ // else: proceed on an ambient login
1124
+ }
1125
+ }
1126
+ else if (support.authFailure === "fail") {
1127
+ throw new Error(support.loginNote);
1128
+ }
1129
+ }
1130
+ const images = turn.images ?? [];
1131
+ const accepts = session.initResult?.agentCapabilities?.promptCapabilities?.image === true ||
1132
+ support.acceptsUnadvertisedImages?.(session.initResult) === true;
1133
+ if (images.length && support.images === true && !accepts) {
1134
+ throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
1135
+ }
1136
+ return accepts;
1137
+ };
1138
+ let runtimeAcceptsImages = await handshake();
1139
+ let init = session.initResult;
1140
+ const cursor = !turn.sessionReset && typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1141
+ let sessionResult = null;
1142
+ let promptTurn = turn;
1143
+ let rebuiltFromReplay = false;
1144
+ for (;;) {
1145
+ const liveSessionId = session.sessionId;
1146
+ if (liveSessionId !== null && session.sessionKey === sessionKey && (cursor === null || cursor === liveSessionId)) {
1147
+ // the pooled session still answers the cursor (or the cursor's
1148
+ // absence) and was established with these exact session inputs:
1149
+ // prompt it directly — no session/load replay, no session/new,
1150
+ // no fresh session bookkeeping
1151
+ break;
1152
+ }
1153
+ // stdio is every agent's baseline; a url server rides only with
1154
+ // an agent that advertised its transport, so an agent without
1155
+ // http/sse never sees an entry it would refuse the session over
1156
+ const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
1157
+ let loaded = false;
1158
+ if (cursor) {
793
1159
  try {
794
- await request("authenticate", { methodId }, INIT_TIMEOUT);
1160
+ await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
1161
+ if (result) {
1162
+ loaded = true;
1163
+ session.sessionId = cursor;
1164
+ session.sessionKey = sessionKey;
1165
+ receiveModelVariants(result);
1166
+ }
1167
+ });
795
1168
  }
796
- catch {
797
- if (support.authFailure === "fail")
798
- throw new Error(support.loginNote);
799
- // else: proceed on an ambient login
1169
+ catch (error) {
1170
+ const classification = support.classifyError?.(error);
1171
+ // OpenCode encodes ACPSessionNotFoundError as invalidParams
1172
+ // with just the rejected sessionId. Other invalidParams
1173
+ // responses (model/config errors) must not erase history.
1174
+ const data = error?.data;
1175
+ const missingSession = data && typeof data === "object" && !Array.isArray(data)
1176
+ && data.sessionId === cursor && Object.keys(data).length === 1;
1177
+ if (classification === "invalid_credentials" || classification === "inactive_subscription"
1178
+ || (error?.code === -32602 && !missingSession))
1179
+ throw error;
1180
+ /* session gone, load unsupported, or too slow — the
1181
+ * fallbacks below choose between one fresh process and a
1182
+ * genuinely new session */
800
1183
  }
801
1184
  }
802
- else if (support.authFailure === "fail") {
803
- throw new Error(support.loginNote);
1185
+ if (loaded)
1186
+ break;
1187
+ if (cursor && liveSessionId === cursor) {
1188
+ // The agent refused (or never answered) re-establishing its
1189
+ // own live session on this process. Continuity outranks the
1190
+ // saved handshake: close the pooled child and resume the
1191
+ // recorded session on a fresh one — the pre-pool path every
1192
+ // agent already supports. A load that fails there too means
1193
+ // the session is genuinely gone; the loop falls through to
1194
+ // session/new on the replacement child.
1195
+ session.current = null;
1196
+ closeSession(threadId, "reestablish");
1197
+ session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
1198
+ sessions.set(threadId, session);
1199
+ session.current = current;
1200
+ runtimeAcceptsImages = await handshake();
1201
+ init = session.initResult;
1202
+ continue;
804
1203
  }
805
- }
806
- const images = turn.images ?? [];
807
- const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
808
- support.acceptsUnadvertisedImages?.(init) === true;
809
- if (images.length && support.images === true && !runtimeAcceptsImages) {
810
- throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
811
- }
812
- // stdio is every agent's baseline; a url server rides only with
813
- // an agent that advertised its transport, so an agent without
814
- // http/sse never sees an entry it would refuse the session over
815
- const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
816
- const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
817
- // a fresh native session forgets what the previous one allowed
818
- if (!cursor)
1204
+ // a genuinely fresh native session forgets what the previous
1205
+ // one allowed
819
1206
  sessionAllows.delete(threadId);
820
- let sessionResult = null;
821
- if (cursor) {
822
- try {
823
- sessionResult = await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
824
- if (result) {
825
- sessionId = cursor;
826
- receiveModelVariants(result);
827
- }
1207
+ if (cursor) {
1208
+ const recovery = recoveryPromptFor({
1209
+ recoveryText: turn.recoveryText,
1210
+ currentText: turn.text,
1211
+ failure: "before-accept",
828
1212
  });
1213
+ promptTurn = { ...turn, text: recovery.text };
1214
+ rebuiltFromReplay = recovery.replayed;
829
1215
  }
830
- catch {
831
- /* session gone, load unsupported, or too slow — start fresh */
832
- }
833
- }
834
- if (!sessionId) {
835
1216
  sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
836
- sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
1217
+ session.sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
1218
+ session.sessionKey = sessionKey;
837
1219
  receiveModelVariants(result);
838
1220
  });
839
- if (!sessionId)
840
- throw new Error("session/new returned no sessionId");
1221
+ break;
841
1222
  }
1223
+ // every establishment path leaves a native session id behind
1224
+ const sessionId = session.sessionId;
1225
+ if (sessionId === null)
1226
+ throw new Error("session/new returned no sessionId");
842
1227
  let selectedModel = null;
843
1228
  let sessionStarted = false;
844
1229
  const emitSessionStarted = () => {
@@ -850,15 +1235,16 @@ export function createAcpDriver(support) {
850
1235
  type: "session.started",
851
1236
  sessionId,
852
1237
  model: selectedModel ?? init?._meta?.modelState?.currentModelId ?? cliTurn.model ?? null,
1238
+ ...(rebuiltFromReplay ? { rebuilt: true } : {}),
853
1239
  });
854
1240
  };
855
1241
  try {
856
1242
  if (support.selectModel) {
857
1243
  const { configId } = support.selectModel;
858
- selectedModel = modelOf(sessionConfigResult);
1244
+ selectedModel = modelOf(session.sessionConfigResult);
859
1245
  if (cliTurn.model && cliTurn.model !== selectedModel) {
860
1246
  sessionResult = await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT, receiveModelVariants);
861
- selectedModel = modelOf(sessionConfigResult);
1247
+ selectedModel = modelOf(session.sessionConfigResult);
862
1248
  // an agent that answers OK but keeps its old model is worse than
863
1249
  // one that errors: it burns a paid turn on the wrong thing
864
1250
  if (selectedModel !== cliTurn.model) {
@@ -876,7 +1262,7 @@ export function createAcpDriver(support) {
876
1262
  ? sessionResult.models.availableModels
877
1263
  : [],
878
1264
  });
879
- // initialize's currentModelId is the CLI default (grok-4.6),
1265
+ // initialize's currentModelId is the CLI default,
880
1266
  // not the model this turn asked for. After a successful pin,
881
1267
  // report the slug we set so the UI does not claim otherwise.
882
1268
  if (!selectedModel && cliTurn.model)
@@ -898,24 +1284,23 @@ export function createAcpDriver(support) {
898
1284
  }
899
1285
  emitSessionStarted();
900
1286
  const text = support.buildPromptText
901
- ? support.buildPromptText(turn)
902
- : turn.system
903
- ? `${turn.system}\n\n${turn.text}`
904
- : turn.text;
1287
+ ? support.buildPromptText(promptTurn)
1288
+ : promptTurn.system
1289
+ ? `${promptTurn.system}\n\n${promptTurn.text}`
1290
+ : promptTurn.text;
905
1291
  const imageBlocks = support.images === true && runtimeAcceptsImages
906
- ? await readAcpImageBlocks(images)
1292
+ ? await readAcpImageBlocks(turn.images ?? [])
907
1293
  : [];
908
- if (support.modelVariants && cliTurn.model && modelOf(sessionConfigResult) !== cliTurn.model) {
1294
+ if (support.modelVariants && cliTurn.model && modelOf(session.sessionConfigResult) !== cliTurn.model) {
909
1295
  throw new Error(`${support.displayName} changed model before the prompt`);
910
1296
  }
911
1297
  if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
912
1298
  throw new Error(`${support.displayName} changed variant before the prompt`);
913
1299
  }
914
1300
  state.promptSent = true;
915
- const result = await request("session/prompt", {
916
- sessionId,
917
- prompt: [{ type: "text", text }, ...imageBlocks],
918
- });
1301
+ const promptIdleMs = promptIdleTimeoutMs();
1302
+ 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
+ "Raise OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS if this model legitimately takes longer to answer.");
919
1304
  // opencode 1.18.18 reports usage at the result root; grok and
920
1305
  // gemini put it under _meta. Read both rather than lose the count.
921
1306
  const usage = result?.usage ?? result?._meta ?? {};
@@ -929,9 +1314,9 @@ export function createAcpDriver(support) {
929
1314
  }
930
1315
  const reason = result?.stopReason;
931
1316
  if (reason === "end_turn")
932
- settle(true, null);
1317
+ settle(threadId, session, true, null);
933
1318
  else if (reason === "cancelled")
934
- settle(true, "cancelled");
1319
+ settle(threadId, session, true, "cancelled");
935
1320
  else {
936
1321
  const errorMessage = typeof result?.error === "string" && result.error
937
1322
  ? result.error
@@ -943,7 +1328,7 @@ export function createAcpDriver(support) {
943
1328
  type: "runtime.error",
944
1329
  message: errorMessage,
945
1330
  });
946
- settle(false, reason ?? "failed");
1331
+ settle(threadId, session, false, reason ?? "failed");
947
1332
  }
948
1333
  }
949
1334
  catch (e) {
@@ -961,7 +1346,16 @@ export function createAcpDriver(support) {
961
1346
  message,
962
1347
  ...(needsAuth ? { setup: true } : {}),
963
1348
  });
964
- settle(false, needsAuth ? "auth_required" : "rpc_error");
1349
+ // Internal RPC failures can leave a live child poisoned just
1350
+ // like a silent prompt. Evict before completion listeners can
1351
+ // start the next turn. Never replay this accepted prompt: it
1352
+ // may already have executed tools. The next explicit turn can
1353
+ // resume the recorded session on a fresh process.
1354
+ if (!needsAuth && (e?.acpPromptStall === true || e?.acpSessionFailure === true
1355
+ || code === "upstream_outage") && session.child.exitCode === null && !session.closing) {
1356
+ closeSession(threadId, e?.acpPromptStall === true ? "prompt-stall" : "rpc-failure");
1357
+ }
1358
+ settle(threadId, session, false, needsAuth ? "auth_required" : "rpc_error");
965
1359
  }
966
1360
  }
967
1361
  })();
@@ -972,7 +1366,7 @@ export function createAcpDriver(support) {
972
1366
  if (support.snapshot)
973
1367
  return support.snapshot(env, config, instanceId);
974
1368
  const version = await new Promise((resolve) => {
975
- execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim()));
1369
+ execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(err ? null : versionFromProbe(stdout, stderr)));
976
1370
  });
977
1371
  if (!version)
978
1372
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
@@ -1024,6 +1418,9 @@ export function createAcpDriver(support) {
1024
1418
  stopAll: async () => {
1025
1419
  for (const { stop } of active.values())
1026
1420
  stop();
1421
+ // idle pooled sessions have no running turn — close them too
1422
+ for (const threadId of Array.from(sessions.keys()))
1423
+ closeSession(threadId, "stopAll");
1027
1424
  },
1028
1425
  onEvent: (listener) => {
1029
1426
  listeners.add(listener);
@@ -1033,6 +1430,8 @@ export function createAcpDriver(support) {
1033
1430
  dispose: async () => {
1034
1431
  for (const { stop } of active.values())
1035
1432
  stop();
1433
+ for (const threadId of Array.from(sessions.keys()))
1434
+ closeSession(threadId, "dispose");
1036
1435
  listeners.clear();
1037
1436
  },
1038
1437
  };