openmausbot 0.1.85 → 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 (193) 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-CTNNoqSJ.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/container-mcp.js +19 -10
  125. package/dist-server/drivers/agents-proxy.js +16159 -1596
  126. package/dist-server/enterprise/LICENSE +32 -0
  127. package/dist-server/enterprise/server/index.js +5 -5
  128. package/dist-server/index.js +4779 -1667
  129. package/dist-server/mcp-server.js +3 -1
  130. package/dist-server/openmausbot.js +4679 -4266
  131. package/dist-server/pair-cli.js +4679 -4266
  132. package/dist-server/server/admin-activity.js +410 -0
  133. package/dist-server/server/bot-visibility.js +462 -0
  134. package/dist-server/server/box.js +2 -1
  135. package/dist-server/server/browser-runtime.js +1 -1
  136. package/dist-server/server/channel-queue.js +2 -0
  137. package/dist-server/server/cli-prompts.js +24 -2
  138. package/dist-server/server/cli.js +79 -10
  139. package/dist-server/server/composio.js +49 -11
  140. package/dist-server/server/config.js +79 -13
  141. package/dist-server/server/decision-log.js +209 -48
  142. package/dist-server/server/default-model-selection.js +20 -3
  143. package/dist-server/server/delegations.js +11 -9
  144. package/dist-server/server/drivers/acp/core.js +110 -22
  145. package/dist-server/server/drivers/acp/grok.js +4 -3
  146. package/dist-server/server/drivers/agents-call.js +1123 -0
  147. package/dist-server/server/drivers/agents-catalog.js +659 -0
  148. package/dist-server/server/drivers/agents-client.js +28 -0
  149. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  150. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  151. package/dist-server/server/drivers/claude.js +21 -6
  152. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  153. package/dist-server/server/drivers/codex.js +26 -4
  154. package/dist-server/server/drivers/grok.js +2 -1
  155. package/dist-server/server/drivers/openai-compat.js +15 -8
  156. package/dist-server/server/enterprise.js +97 -16
  157. package/dist-server/server/external-runtime.js +83 -0
  158. package/dist-server/server/guarded-requests.js +60 -0
  159. package/dist-server/server/harness/http.js +61 -0
  160. package/dist-server/server/hosted-models.js +151 -0
  161. package/dist-server/server/hosted-slack.js +11 -0
  162. package/dist-server/server/index.js +1718 -348
  163. package/dist-server/server/managed-desktop.js +79 -14
  164. package/dist-server/server/managed-policy.js +153 -0
  165. package/dist-server/server/message-db.js +28 -0
  166. package/dist-server/server/model-context-window.js +7 -0
  167. package/dist-server/server/model-prices.js +121 -0
  168. package/dist-server/server/notify.js +10 -1
  169. package/dist-server/server/peer-roster.js +13 -2
  170. package/dist-server/server/recent-work.js +15 -0
  171. package/dist-server/server/request-auth.js +96 -0
  172. package/dist-server/server/room-handoffs.js +25 -11
  173. package/dist-server/server/routes/hosted-slack.js +20 -0
  174. package/dist-server/server/routes/table.js +14 -0
  175. package/dist-server/server/routines.js +22 -0
  176. package/dist-server/server/spend.js +111 -16
  177. package/dist-server/server/steer-queue.js +6 -1
  178. package/dist-server/server/store.js +292 -21
  179. package/dist-server/server/team-backup.js +10 -1
  180. package/dist-server/server/thread-starters.js +53 -0
  181. package/dist-server/server/usage-ledger.js +31 -8
  182. package/dist-server/server/workspace-backup-policy.js +9 -2
  183. package/dist-server/server/workspace-backup.js +7 -4
  184. package/dist-server/shared/mention-boundary.js +15 -0
  185. package/dist-server/shared/routines.js +7 -1
  186. package/dist-server/shared/team-backup.js +2 -0
  187. package/dist-server/src/lib/composer-attachments.js +16 -0
  188. package/dist-server/vps-container-mcp.js +19 -10
  189. package/enterprise/README.md +33 -12
  190. package/enterprise/server/index.js +5 -5
  191. package/package.json +1 -1
  192. package/dist/assets/index-Ba9G44HI.js +0 -310
  193. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -15,44 +15,88 @@
15
15
  // credentials included), and fire-and-forget — an audit log must never
16
16
  // take down the decision it is auditing.
17
17
  //
18
+ // Layout and retention follow the usage ledger: one file per UTC month under
19
+ // <data>/decisions/YYYY-MM.ndjson, and a month file is deleted only once the
20
+ // whole month is older than the retention window (default 180 days, config
21
+ // `decisions.retentionDays` or OMB_DECISION_RETENTION_DAYS), so at least that
22
+ // much history is always kept. Older servers wrote one decisions.ndjson that
23
+ // rotated to .1 at 4 MB; both are still read, never written, and removed by
24
+ // the same window once their last row is older than it.
25
+ //
18
26
  // Deliberately NOT covered: ask_bot peer-approval cards. They never cross
19
27
  // the runtime bus (peer-approval.ts appends its cards straight to the
20
28
  // store), so wiring them here would mean a second, parallel tap — a
21
29
  // separate change if it earns its keep.
22
- import { readFileSync } from "node:fs";
23
- import { appendFile, rename, stat } from "node:fs/promises";
24
- import { join } from "node:path";
30
+ import { AsyncLocalStorage } from "node:async_hooks";
31
+ import { readdirSync, readFileSync } from "node:fs";
32
+ import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises";
33
+ import { basename, join } from "node:path";
25
34
  import { redactSecrets } from "./redact.js";
26
- const FILE_NAME = "decisions.ndjson";
27
- // Rotation is logrotate at its simplest: when the live file crosses the cap
28
- // it becomes `.1` (clobbering the previous `.1`) and a fresh file starts.
29
- // Total disk is bounded at ~2× the cap; at a few hundred bytes per row that
30
- // is years of human-scale approvals, and anything fancier — dated segments,
31
- // compression — is more machinery than an audit trail this size warrants.
32
- const MAX_BYTES = 4 * 1024 * 1024;
35
+ import { csvCell } from "./usage-ledger.js";
36
+ const DIR = "decisions";
37
+ const LEGACY_FILE = "decisions.ndjson";
38
+ const MONTH_FILE = /^(\d{4})-(\d{2})\.ndjson$/;
39
+ const DAY_MS = 24 * 60 * 60_000;
40
+ const PRUNE_EVERY_MS = 60 * 60_000;
41
+ export const DEFAULT_DECISION_RETENTION_DAYS = 180;
42
+ export const MAX_DECISION_RETENTION_DAYS = 3650;
33
43
  const writeQueues = new Map();
34
- async function writeDecision(dataDir, row, maxBytes) {
35
- const file = join(dataDir, FILE_NAME);
36
- try {
37
- if ((await stat(file)).size >= maxBytes)
38
- await rename(file, `${file}.1`);
39
- }
40
- catch {
41
- /* no live file yet — nothing to rotate */
44
+ const lastPrune = new Map();
45
+ let retentionDays = () => DEFAULT_DECISION_RETENTION_DAYS;
46
+ /** The server binds its configured window once; see decisionRetentionDays. */
47
+ export function bindDecisionRetention(provider) {
48
+ retentionDays = provider;
49
+ }
50
+ /** The window in force now. The admin activity log (admin-activity.ts)
51
+ * keeps its months for the same time, so one setting covers both. */
52
+ export function boundRetentionDays() {
53
+ return retentionDays();
54
+ }
55
+ /** The retention window in days: OMB_DECISION_RETENTION_DAYS when it is a
56
+ * whole number in range, else the configured value, else 180. */
57
+ export function decisionRetentionDays(configured, env = process.env) {
58
+ const fromEnv = env.OMB_DECISION_RETENTION_DAYS?.trim();
59
+ const parsed = fromEnv ? Number(fromEnv) : NaN;
60
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_DECISION_RETENTION_DAYS)
61
+ return parsed;
62
+ if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= MAX_DECISION_RETENTION_DAYS)
63
+ return configured;
64
+ return DEFAULT_DECISION_RETENTION_DAYS;
65
+ }
66
+ const actorScope = new AsyncLocalStorage();
67
+ /** Run `work` as a person's card answer: every `source: "user"` row it
68
+ * writes names `actor`, without each resolver having to thread it through. */
69
+ export function withDecisionActor(actor, work) {
70
+ return actorScope.run(actor, work);
71
+ }
72
+ function monthKey(at) {
73
+ return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
74
+ }
75
+ export function decisionFileFor(dataDir, at) {
76
+ return join(dataDir, DIR, `${monthKey(at)}.ndjson`);
77
+ }
78
+ async function writeDecision(dataDir, record) {
79
+ const at = new Date(record.at);
80
+ await mkdir(join(dataDir, DIR), { recursive: true, mode: 0o700 });
81
+ await appendFile(decisionFileFor(dataDir, at), JSON.stringify(record) + "\n", { mode: 0o600 });
82
+ if (at.getTime() - (lastPrune.get(dataDir) ?? 0) >= PRUNE_EVERY_MS) {
83
+ lastPrune.set(dataDir, at.getTime());
84
+ await pruneDecisions(dataDir, retentionDays(), at);
42
85
  }
43
- const record = redactSecrets({ at: new Date().toISOString(), ...row });
44
- await appendFile(file, JSON.stringify(record) + "\n", { mode: 0o600 });
45
86
  }
46
87
  /** Append one decision row. Fire-and-forget, mirroring the event bus tee:
47
88
  * the fold that calls this is delivering approvals and cards, and a full
48
89
  * disk must not turn into denied tools. */
49
- export function appendDecision(dataDir, row, opts) {
90
+ export function appendDecision(dataDir, row) {
91
+ const actor = row.actor ?? (row.source === "user" ? actorScope.getStore() : undefined);
92
+ // Redact now, not when the queue drains: the row is what was true at the
93
+ // moment of the decision.
94
+ const record = redactSecrets({ at: new Date().toISOString(), ...row, ...(actor ? { actor } : {}) });
50
95
  const previous = writeQueues.get(dataDir) ?? Promise.resolve();
51
- // Serialize stat → optional rotate → append for each directory. Without
52
- // this queue two simultaneous approvals can both rotate, overwrite .1,
53
- // or append out of decision order.
96
+ // Serialize appends (and the occasional prune) per directory, so two
97
+ // simultaneous approvals keep their decision order.
54
98
  const queued = previous
55
- .then(() => writeDecision(dataDir, row, opts?.maxBytes ?? MAX_BYTES))
99
+ .then(() => writeDecision(dataDir, record))
56
100
  .catch(() => {
57
101
  /* logging must never take down the fold */
58
102
  });
@@ -67,40 +111,157 @@ export function appendDecision(dataDir, row, opts) {
67
111
  export async function flushDecisionLog(dataDir) {
68
112
  await writeQueues.get(dataDir);
69
113
  }
114
+ /** Delete what the window no longer covers: a month file once the first
115
+ * instant after its month is at or before `now - days`, a legacy file once
116
+ * its last write is. Returns the names removed. Never throws. */
117
+ export async function pruneDecisions(dataDir, days, now = new Date()) {
118
+ const cutoff = now.getTime() - days * DAY_MS;
119
+ const removed = await pruneMonthFiles(join(dataDir, DIR), days, now);
120
+ for (const name of [`${LEGACY_FILE}.1`, LEGACY_FILE]) {
121
+ try {
122
+ if ((await stat(join(dataDir, name))).mtimeMs > cutoff)
123
+ continue;
124
+ await unlink(join(dataDir, name));
125
+ removed.push(name);
126
+ }
127
+ catch {
128
+ /* absent */
129
+ }
130
+ }
131
+ return removed;
132
+ }
133
+ /** Delete the `YYYY-MM.ndjson` files in `dir` whose whole month is at or
134
+ * before `now - days`. Returns the names removed. Never throws. Shared with
135
+ * the admin activity log. */
136
+ export async function pruneMonthFiles(dir, days, now = new Date()) {
137
+ const cutoff = now.getTime() - days * DAY_MS;
138
+ const removed = [];
139
+ let names = [];
140
+ try {
141
+ names = await readdir(dir);
142
+ }
143
+ catch {
144
+ /* nothing written in the new layout yet */
145
+ }
146
+ for (const name of names) {
147
+ const match = MONTH_FILE.exec(name);
148
+ if (!match || Date.UTC(Number(match[1]), Number(match[2]), 1) > cutoff)
149
+ continue;
150
+ try {
151
+ await unlink(join(dir, name));
152
+ removed.push(name);
153
+ }
154
+ catch {
155
+ /* already gone, or not ours to remove */
156
+ }
157
+ }
158
+ return removed;
159
+ }
70
160
  const isDecisionRow = (value) => typeof value === "object" &&
71
161
  value !== null &&
72
162
  typeof value.at === "string" &&
73
163
  typeof value.threadId === "string" &&
74
164
  typeof value.decision === "string" &&
75
165
  typeof value.source === "string";
76
- /** The newest `limit` rows, oldest first — the same order the inspector
77
- * uses for thread events. Reads `.1` before the live file so a request
78
- * right after a rotation still sees history instead of a nearly empty
79
- * log; whole-file reads are fine here because rotation caps both files,
80
- * unlike the unbounded per-thread event logs that need a tail walk. */
81
- export function readDecisions(dataDir, limit) {
82
- const file = join(dataDir, FILE_NAME);
166
+ function readRows(path) {
167
+ let text;
168
+ try {
169
+ text = readFileSync(path, "utf8");
170
+ }
171
+ catch {
172
+ return [];
173
+ }
83
174
  const rows = [];
84
- for (const path of [`${file}.1`, file]) {
85
- let text;
175
+ for (const line of text.split("\n")) {
176
+ if (!line)
177
+ continue;
86
178
  try {
87
- text = readFileSync(path, "utf8");
179
+ const value = JSON.parse(line);
180
+ if (isDecisionRow(value))
181
+ rows.push(value);
88
182
  }
89
183
  catch {
90
- continue;
184
+ /* a line torn mid-write — skip the fragment, keep the rest */
91
185
  }
92
- for (const line of text.split("\n")) {
93
- if (!line)
94
- continue;
95
- try {
96
- const value = JSON.parse(line);
97
- if (isDecisionRow(value))
98
- rows.push(value);
99
- }
100
- catch {
101
- /* a line torn mid-write — skip the fragment, keep the rest */
102
- }
186
+ }
187
+ return rows;
188
+ }
189
+ /** Every file that may hold rows, oldest first: the legacy pair (which only
190
+ * ever holds rows older than any month file), then the months in order. */
191
+ function sources(dataDir) {
192
+ let months = [];
193
+ try {
194
+ months = readdirSync(join(dataDir, DIR)).filter((name) => MONTH_FILE.test(name)).sort();
195
+ }
196
+ catch {
197
+ /* no month files yet */
198
+ }
199
+ return [join(dataDir, `${LEGACY_FILE}.1`), join(dataDir, LEGACY_FILE), ...months.map((name) => join(dataDir, DIR, name))];
200
+ }
201
+ /** The newest `limit` rows, oldest first — the same order the inspector
202
+ * uses for thread events. Reads month files newest first and stops once it
203
+ * has enough, so a long history costs no more than the page asked for. */
204
+ export function readDecisions(dataDir, limit) {
205
+ const files = sources(dataDir);
206
+ const chunks = [];
207
+ let count = 0;
208
+ for (let index = files.length - 1; index >= 0 && count < limit; index -= 1) {
209
+ const rows = readRows(files[index]);
210
+ chunks.unshift(rows);
211
+ count += rows.length;
212
+ }
213
+ return chunks.flat().slice(-limit);
214
+ }
215
+ /** Every row whose time falls inside the range, oldest first. */
216
+ export function readDecisionRange(dataDir, range) {
217
+ const from = range.from.getTime();
218
+ const to = range.to.getTime();
219
+ const first = monthKey(range.from);
220
+ const last = monthKey(range.to);
221
+ const rows = [];
222
+ for (const file of sources(dataDir)) {
223
+ // Month files outside the range are skipped unread; legacy files have no month.
224
+ const month = MONTH_FILE.exec(basename(file));
225
+ if (month && (`${month[1]}-${month[2]}` < first || `${month[1]}-${month[2]}` > last))
226
+ continue;
227
+ for (const row of readRows(file)) {
228
+ const at = Date.parse(row.at);
229
+ if (at >= from && at <= to)
230
+ rows.push(row);
103
231
  }
104
232
  }
105
- return rows.slice(-limit);
233
+ return rows;
234
+ }
235
+ export function actorLabel(actor) {
236
+ if (!actor)
237
+ return "";
238
+ if (actor.kind === "loopback")
239
+ return "This computer";
240
+ if (actor.kind === "worker")
241
+ return "Local service";
242
+ return actor.email ?? actor.label;
243
+ }
244
+ /** One line per decision, spreadsheet-ready. Cells go through the usage
245
+ * CSV's formula neutralising, and every row through redaction again, so an
246
+ * export never carries more than the log itself may. */
247
+ export function decisionsCsv(rows) {
248
+ const header = ["time", "decision", "source", "bot", "tool", "summary", "rule", "unattended", "answered_by", "thread", "request"];
249
+ const lines = [header.join(",")];
250
+ for (const raw of rows) {
251
+ const row = redactSecrets(raw);
252
+ lines.push([
253
+ row.at,
254
+ row.decision,
255
+ row.source,
256
+ row.botName || row.botId || "",
257
+ row.tool ?? "",
258
+ row.summary ?? "",
259
+ row.rule ?? "",
260
+ row.unattended ? "yes" : "",
261
+ actorLabel(row.actor),
262
+ row.threadId,
263
+ row.requestId ?? "",
264
+ ].map(csvCell).join(","));
265
+ }
266
+ return lines.join("\n") + "\n";
106
267
  }
@@ -1,10 +1,14 @@
1
+ const claudeFirst = (instances) => instances.find((instance) => instance.driverKind === "claudeAgent") ?? instances[0];
1
2
  /** A saved choice is intentional: an unavailable provider or removed model
2
3
  * sends new bots to setup instead of silently changing their provider. */
3
- export function selectDefaultModelSelection(instances, preferred) {
4
+ export function selectDefaultModelSelection(instances, preferred, context = {}) {
4
5
  if (preferred) {
5
6
  const instance = instances.find((candidate) => candidate.instanceId === preferred.instanceId);
6
7
  if (instance?.snapshot.state !== "available" ||
7
8
  instance.snapshot.authenticated === false ||
9
+ // An organisation that disallows the saved engine makes it unusable,
10
+ // exactly like an unavailable one: setup, never another provider.
11
+ context.refusal?.(instance) !== undefined ||
8
12
  (preferred.variant !== undefined && !instance.capabilities?.modelVariants) ||
9
13
  !(instance.models.default === preferred.model || instance.models.options.some((model) => model.id === preferred.model))) {
10
14
  return { instanceId: "", model: "" };
@@ -16,7 +20,20 @@ export function selectDefaultModelSelection(instances, preferred) {
16
20
  delete selection.effort;
17
21
  return selection;
18
22
  }
19
- const available = instances.filter((instance) => instance.snapshot.state === "available");
20
- const pick = available.find((instance) => instance.driverKind === "claudeAgent") ?? available[0];
23
+ // The organisation's policy is inert (no refusal) unless this desktop is enrolled.
24
+ const available = instances.filter((instance) => instance.snapshot.state === "available" && context.refusal?.(instance) === undefined);
25
+ const company = context.company;
26
+ let pick;
27
+ if (company && instances.some((instance) => company(instance.instanceId))) {
28
+ // Enrolled: an installed but signed-out personal Claude would send every
29
+ // new bot to setup although a Company model can run. Prefer instances that
30
+ // can run a turn now; a working personal engine still wins, so billing
31
+ // stays the person's explicit choice.
32
+ const ready = available.filter((instance) => instance.access === "custom" || instance.snapshot.authenticated !== false);
33
+ pick = claudeFirst(ready.filter((instance) => !company(instance.instanceId))) ?? claudeFirst(ready) ?? claudeFirst(available);
34
+ }
35
+ else {
36
+ pick = claudeFirst(available);
37
+ }
21
38
  return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" };
22
39
  }
@@ -642,17 +642,19 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
642
642
  await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
643
643
  return "dispatched";
644
644
  }
645
- /** "Is the target free to take this handoff right now?" What "busy" means
646
- * depends on where the turn will run: a classic delegation lands in the
647
- * target's active thread, so it needs the bot to be idle; a fresh-thread
648
- * handoff needs only a free slot. This is the single free/busy test shared
649
- * by the expiry decision in `processOne` and the hold decision below, so
650
- * the two can never disagree about whether a handoff could have been
651
- * delivered right now. */
645
+ /** "Is the target free to take this handoff right now?" Both shapes ask for
646
+ * a free capacity slot, never whole-bot idleness: a classic delegation lands
647
+ * in the target's standing thread and applies the same admission startTurn
648
+ * uses for a direct turn there, while a fresh-thread handoff needs any free
649
+ * slot. This is the single free/busy test shared by the expiry decision in
650
+ * `processOne` and the hold decision below, so the two can never disagree
651
+ * about whether a handoff could have been delivered right now. */
652
652
  function targetCanTakeTurn(bus, target, item) {
653
653
  return item.targetThreadId
654
654
  ? (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
655
- : !target.busy;
655
+ : bus.canAdmitDirectTurn
656
+ ? bus.canAdmitDirectTurn(target.id, target.threadId)
657
+ : !target.busy;
656
658
  }
657
659
  /** A busy target holds the handoff. Neither counts busy periods — the only
658
660
  * bound is DELEGATION_TTL_MS, checked in processOne before this runs (using
@@ -724,7 +726,7 @@ function sourceThreadBelongsToBot(store, botId, threadId) {
724
726
  * have moved either bot, or narrowed the sender's peers, in between. */
725
727
  function dropIfUnreachable(bus, sender, target, sourceThreadId, item) {
726
728
  const sectionsDiffer = !canAccessTeam(sender, target.section);
727
- if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target.id))
729
+ if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target))
728
730
  return false;
729
731
  const reason = sectionsDiffer
730
732
  ? "bots now belong to different sections"
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
30
30
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
31
31
  import { createHash } from "node:crypto";
32
- import { PROVIDER_CREDENTIAL_ENV, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
32
+ import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
33
33
  import { decodeInjectId } from "../local-inject.js";
34
34
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
35
35
  /**
@@ -45,6 +45,33 @@ import { supportsApprovalMode } from "../../../shared/approval-mode.js";
45
45
  import { appendNative } from "../native.js";
46
46
  import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
47
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
+ }
48
75
  const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
49
76
  const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
50
77
  const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
@@ -171,6 +198,17 @@ function sessionOperationKey(toolCall) {
171
198
  locations: toolCall?.locations,
172
199
  }));
173
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
+ }
174
212
  export function createAcpDriver(support) {
175
213
  const DRIVER_KIND = support.driverKind;
176
214
  const SOURCE = support.nativeSource;
@@ -205,6 +243,8 @@ export function createAcpDriver(support) {
205
243
  if (!allowedCredentials.has(key))
206
244
  delete env[key];
207
245
  }
246
+ // The operator's own secrets are outside any driver's allowlist.
247
+ stripControlPlaneEnv(env);
208
248
  support.transformEnv?.(env, activeConfig, instanceId);
209
249
  return env;
210
250
  };
@@ -385,7 +425,25 @@ export function createAcpDriver(support) {
385
425
  session.current = null;
386
426
  active.delete(threadId);
387
427
  current.flushAssistantText();
388
- emit({ ...base(threadId, current.turnId), type: "turn.completed", ok, stopReason, cost: null });
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";
440
+ emit({
441
+ ...base(threadId, current.turnId),
442
+ type: "runtime.error",
443
+ message: `${DRIVER_KIND} ended the turn with no reply, image, or tool result`,
444
+ });
445
+ }
446
+ emit({ ...base(threadId, current.turnId), type: "turn.completed", ok: finalOk, stopReason: finalStopReason, cost: null });
389
447
  if (session.child.exitCode === null && !session.closing && !session.dead) {
390
448
  armIdle(threadId);
391
449
  }
@@ -426,7 +484,7 @@ export function createAcpDriver(support) {
426
484
  if (timeoutMs) {
427
485
  timer = setTimeout(() => {
428
486
  rpcPending.delete(id);
429
- reject(new Error(`${method} timed out`));
487
+ reject(Object.assign(new Error(`${method} timed out`), { acpSessionFailure: true }));
430
488
  }, timeoutMs);
431
489
  timer.unref?.();
432
490
  }
@@ -455,6 +513,7 @@ export function createAcpDriver(support) {
455
513
  };
456
514
  armIdle();
457
515
  rpcPending.set(id, {
516
+ method,
458
517
  // Consume configuration in wire order: an update following this
459
518
  // response may arrive before the awaiting continuation resumes.
460
519
  resolve: (result) => { receive?.(result); resolve(result); },
@@ -686,6 +745,7 @@ export function createAcpDriver(support) {
686
745
  const delta = content?.text;
687
746
  if (content?.type === "image" && typeof content.data === "string" && content.data) {
688
747
  current.flushAssistantText();
748
+ current.state.producedItem = true;
689
749
  emit({
690
750
  ...base(threadId, current.turnId),
691
751
  type: "item.completed",
@@ -722,6 +782,7 @@ export function createAcpDriver(support) {
722
782
  }
723
783
  case "tool_call_update": {
724
784
  if (u.status === "completed" || u.status === "failed") {
785
+ current.state.producedItem = true;
725
786
  emit({
726
787
  ...base(threadId, current.turnId),
727
788
  type: "item.completed",
@@ -788,9 +849,7 @@ export function createAcpDriver(support) {
788
849
  if (pend.idleTimer)
789
850
  clearTimeout(pend.idleTimer);
790
851
  if (msg.error) {
791
- const error = new Error(msg.error.message ?? JSON.stringify(msg.error));
792
- Object.assign(error, { code: msg.error.code, data: msg.error.data });
793
- pend.reject(error);
852
+ pend.reject(acpRpcError(msg.error, pend.method));
794
853
  }
795
854
  else {
796
855
  pend.resolve(msg.result);
@@ -923,6 +982,10 @@ export function createAcpDriver(support) {
923
982
  const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
924
983
  const contractKey = JSON.stringify([launch.command, launch.args ?? [], spawnArgs, cwd, turnConfig.fullAuto === true, envFingerprint]);
925
984
  const sessionKey = JSON.stringify(mcpServers);
985
+ if (turn.sessionReset) {
986
+ closeSession(threadId, "reset");
987
+ sessionAllows.delete(threadId);
988
+ }
926
989
  const pooled = sessions.get(threadId);
927
990
  let session;
928
991
  if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey) {
@@ -948,7 +1011,7 @@ export function createAcpDriver(support) {
948
1011
  // respawns the child, every wire call must reach the live record, so
949
1012
  // nothing captures the connection off it.
950
1013
  const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
951
- const state = { settled: false, promptSent: false, text: "" };
1014
+ const state = { settled: false, promptSent: false, text: "", producedItem: false };
952
1015
  const asks = new Map();
953
1016
  const modelOf = (result) => {
954
1017
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
@@ -987,6 +1050,7 @@ export function createAcpDriver(support) {
987
1050
  state.text = "";
988
1051
  if (!text.trim())
989
1052
  return;
1053
+ state.producedItem = true;
990
1054
  emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
991
1055
  };
992
1056
  const current = {
@@ -1073,8 +1137,10 @@ export function createAcpDriver(support) {
1073
1137
  };
1074
1138
  let runtimeAcceptsImages = await handshake();
1075
1139
  let init = session.initResult;
1076
- const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1140
+ const cursor = !turn.sessionReset && typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
1077
1141
  let sessionResult = null;
1142
+ let promptTurn = turn;
1143
+ let rebuiltFromReplay = false;
1078
1144
  for (;;) {
1079
1145
  const liveSessionId = session.sessionId;
1080
1146
  if (liveSessionId !== null && session.sessionKey === sessionKey && (cursor === null || cursor === liveSessionId)) {
@@ -1100,7 +1166,17 @@ export function createAcpDriver(support) {
1100
1166
  }
1101
1167
  });
1102
1168
  }
1103
- catch {
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;
1104
1180
  /* session gone, load unsupported, or too slow — the
1105
1181
  * fallbacks below choose between one fresh process and a
1106
1182
  * genuinely new session */
@@ -1127,8 +1203,16 @@ export function createAcpDriver(support) {
1127
1203
  }
1128
1204
  // a genuinely fresh native session forgets what the previous
1129
1205
  // one allowed
1130
- if (!cursor)
1131
- sessionAllows.delete(threadId);
1206
+ sessionAllows.delete(threadId);
1207
+ if (cursor) {
1208
+ const recovery = recoveryPromptFor({
1209
+ recoveryText: turn.recoveryText,
1210
+ currentText: turn.text,
1211
+ failure: "before-accept",
1212
+ });
1213
+ promptTurn = { ...turn, text: recovery.text };
1214
+ rebuiltFromReplay = recovery.replayed;
1215
+ }
1132
1216
  sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
1133
1217
  session.sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
1134
1218
  session.sessionKey = sessionKey;
@@ -1151,6 +1235,7 @@ export function createAcpDriver(support) {
1151
1235
  type: "session.started",
1152
1236
  sessionId,
1153
1237
  model: selectedModel ?? init?._meta?.modelState?.currentModelId ?? cliTurn.model ?? null,
1238
+ ...(rebuiltFromReplay ? { rebuilt: true } : {}),
1154
1239
  });
1155
1240
  };
1156
1241
  try {
@@ -1177,7 +1262,7 @@ export function createAcpDriver(support) {
1177
1262
  ? sessionResult.models.availableModels
1178
1263
  : [],
1179
1264
  });
1180
- // initialize's currentModelId is the CLI default (grok-4.6),
1265
+ // initialize's currentModelId is the CLI default,
1181
1266
  // not the model this turn asked for. After a successful pin,
1182
1267
  // report the slug we set so the UI does not claim otherwise.
1183
1268
  if (!selectedModel && cliTurn.model)
@@ -1199,10 +1284,10 @@ export function createAcpDriver(support) {
1199
1284
  }
1200
1285
  emitSessionStarted();
1201
1286
  const text = support.buildPromptText
1202
- ? support.buildPromptText(turn)
1203
- : turn.system
1204
- ? `${turn.system}\n\n${turn.text}`
1205
- : turn.text;
1287
+ ? support.buildPromptText(promptTurn)
1288
+ : promptTurn.system
1289
+ ? `${promptTurn.system}\n\n${promptTurn.text}`
1290
+ : promptTurn.text;
1206
1291
  const imageBlocks = support.images === true && runtimeAcceptsImages
1207
1292
  ? await readAcpImageBlocks(turn.images ?? [])
1208
1293
  : [];
@@ -1261,11 +1346,14 @@ export function createAcpDriver(support) {
1261
1346
  message,
1262
1347
  ...(needsAuth ? { setup: true } : {}),
1263
1348
  });
1264
- // A prompt that went idle has a wedged child under the RPC — it
1265
- // will never answer the next prompt either. Do not leave it
1266
- // pooled: close it so the next turn spawns a fresh agent.
1267
- if (e?.acpPromptStall === true && session.child.exitCode === null && !session.closing) {
1268
- closeSession(threadId, "prompt-stall");
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");
1269
1357
  }
1270
1358
  settle(threadId, session, false, needsAuth ? "auth_required" : "rpc_error");
1271
1359
  }
@@ -1278,7 +1366,7 @@ export function createAcpDriver(support) {
1278
1366
  if (support.snapshot)
1279
1367
  return support.snapshot(env, config, instanceId);
1280
1368
  const version = await new Promise((resolve) => {
1281
- 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)));
1282
1370
  });
1283
1371
  if (!version)
1284
1372
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };