openmausbot 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +4 -3
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/companion/src/routes.js +8 -1
  63. package/dist-server/connector-proxy.js +105 -1
  64. package/dist-server/container-mcp.js +1032 -165
  65. package/dist-server/drivers/agents-proxy.js +338 -29
  66. package/dist-server/drivers/phone-proxy.js +33 -2
  67. package/dist-server/index.js +40590 -31811
  68. package/dist-server/local-computer-proxy.js +5 -3
  69. package/dist-server/local-computer.js +53 -17
  70. package/dist-server/mcp-gate.js +2 -0
  71. package/dist-server/mcp-server.js +5 -3
  72. package/dist-server/message-search.worker.js +52 -0
  73. package/dist-server/openmausbot.js +4457 -1399
  74. package/dist-server/pair-cli.js +4457 -1399
  75. package/dist-server/permission-proxy.js +29 -9
  76. package/dist-server/server/admission.js +81 -0
  77. package/dist-server/server/aside-queue.js +294 -0
  78. package/dist-server/server/attachments.js +98 -1
  79. package/dist-server/server/auto-approve.js +4 -3
  80. package/dist-server/server/auto-vm-claims.js +1 -0
  81. package/dist-server/server/bot-attachment.js +107 -0
  82. package/dist-server/server/bot-overview.js +19 -0
  83. package/dist-server/server/bot-package.js +18 -292
  84. package/dist-server/server/bot-profile.js +5 -17
  85. package/dist-server/server/box-create-idempotency.js +3 -3
  86. package/dist-server/server/box.js +57 -55
  87. package/dist-server/server/browser-proxy.js +3 -2
  88. package/dist-server/server/browser-runtime.js +37 -1
  89. package/dist-server/server/cli-api-setup.js +1 -0
  90. package/dist-server/server/cli.js +2 -2
  91. package/dist-server/server/command-allowlist.js +154 -0
  92. package/dist-server/server/composio.js +297 -1
  93. package/dist-server/server/computer-wait.js +22 -9
  94. package/dist-server/server/config.js +84 -12
  95. package/dist-server/server/connector-advertisement.js +165 -0
  96. package/dist-server/server/connector-proxy.js +14 -2
  97. package/dist-server/server/connector-verdict.js +168 -0
  98. package/dist-server/server/container-computer.js +61 -1
  99. package/dist-server/server/contracts.js +22 -0
  100. package/dist-server/server/default-model-selection.js +9 -0
  101. package/dist-server/server/delegations.js +85 -34
  102. package/dist-server/server/drivers/acp/core.js +80 -20
  103. package/dist-server/server/drivers/acp/cursor.js +3 -7
  104. package/dist-server/server/drivers/acp/droid.js +4 -7
  105. package/dist-server/server/drivers/acp/gemini.js +4 -4
  106. package/dist-server/server/drivers/acp/grok.js +4 -9
  107. package/dist-server/server/drivers/acp/hermes.js +3 -5
  108. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  109. package/dist-server/server/drivers/acp/qwen.js +9 -15
  110. package/dist-server/server/drivers/agents-call.js +179 -13
  111. package/dist-server/server/drivers/agents-catalog.js +134 -10
  112. package/dist-server/server/drivers/boxagent.js +160 -36
  113. package/dist-server/server/drivers/builtIn.js +2 -0
  114. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  115. package/dist-server/server/drivers/chat-images.js +54 -0
  116. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  117. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  118. package/dist-server/server/drivers/claude.js +38 -19
  119. package/dist-server/server/drivers/codex-catalog.js +14 -6
  120. package/dist-server/server/drivers/codex-identity.js +2 -1
  121. package/dist-server/server/drivers/codex-instructions.js +48 -6
  122. package/dist-server/server/drivers/codex-release.js +48 -0
  123. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  124. package/dist-server/server/drivers/codex.js +102 -55
  125. package/dist-server/server/drivers/mistral.js +115 -0
  126. package/dist-server/server/drivers/openai-chat.js +180 -33
  127. package/dist-server/server/drivers/openai-compat.js +1 -0
  128. package/dist-server/server/drivers/permission-command.js +35 -0
  129. package/dist-server/server/drivers/phone-proxy.js +47 -2
  130. package/dist-server/server/drivers/pi.js +306 -28
  131. package/dist-server/server/drivers/prompt-split.js +110 -0
  132. package/dist-server/server/drivers/session-idle.js +29 -0
  133. package/dist-server/server/env-path.js +15 -0
  134. package/dist-server/server/fleet.js +1 -1
  135. package/dist-server/server/group-thread-usage.js +103 -0
  136. package/dist-server/server/guarded-requests.js +2 -2
  137. package/dist-server/server/harness/registry.js +1 -0
  138. package/dist-server/server/harness-capabilities.js +46 -0
  139. package/dist-server/server/hosted-models.js +1 -1
  140. package/dist-server/server/index.js +2396 -505
  141. package/dist-server/server/local-computer.js +72 -23
  142. package/dist-server/server/managed-desktop.js +1 -1
  143. package/dist-server/server/mcp-bridge.js +6 -6
  144. package/dist-server/server/message-db.js +22 -42
  145. package/dist-server/server/message-file.js +9 -1
  146. package/dist-server/server/message-search-query.js +43 -0
  147. package/dist-server/server/message-search-worker.js +80 -0
  148. package/dist-server/server/message-search.worker.js +21 -0
  149. package/dist-server/server/model-requests.js +248 -0
  150. package/dist-server/server/new-bot-defaults.js +115 -0
  151. package/dist-server/server/options-card.js +21 -0
  152. package/dist-server/server/org-library.js +1065 -0
  153. package/dist-server/server/package-export.js +627 -39
  154. package/dist-server/server/package-import.js +458 -0
  155. package/dist-server/server/package-parts.js +87 -0
  156. package/dist-server/server/peer-approval.js +16 -7
  157. package/dist-server/server/peer-delivery.js +60 -0
  158. package/dist-server/server/peer-roster.js +67 -0
  159. package/dist-server/server/presets.js +492 -0
  160. package/dist-server/server/profile-requests.js +66 -39
  161. package/dist-server/server/profile-revision.js +9 -1
  162. package/dist-server/server/profile-versions.js +6 -3
  163. package/dist-server/server/provider-key-check.js +3 -2
  164. package/dist-server/server/published-teams.js +80 -0
  165. package/dist-server/server/room-handoffs.js +19 -3
  166. package/dist-server/server/routes/bot-presets.js +25 -0
  167. package/dist-server/server/routine-requests.js +35 -10
  168. package/dist-server/server/routines.js +41 -1
  169. package/dist-server/server/section-context.js +10 -0
  170. package/dist-server/server/skill-fetch.js +72 -14
  171. package/dist-server/server/skills.js +35 -61
  172. package/dist-server/server/steer-queue.js +3 -2
  173. package/dist-server/server/store.js +291 -17
  174. package/dist-server/server/surface.js +10 -4
  175. package/dist-server/server/system-prompt.js +38 -6
  176. package/dist-server/server/team-backup.js +6 -2
  177. package/dist-server/server/team-computers.js +10 -0
  178. package/dist-server/server/team-library.js +6 -2
  179. package/dist-server/server/team-manifest.js +4 -3
  180. package/dist-server/server/team-setup-requests.js +139 -27
  181. package/dist-server/server/thread-events.js +20 -2
  182. package/dist-server/server/tightening-requests.js +405 -0
  183. package/dist-server/server/tts/chatterbox.js +27 -5
  184. package/dist-server/server/turn-context.js +2 -0
  185. package/dist-server/server/turn-watchdog.js +12 -3
  186. package/dist-server/server/usage-ledger.js +19 -2
  187. package/dist-server/server/vps-computer.js +139 -51
  188. package/dist-server/server/vps-container-mcp.js +7 -0
  189. package/dist-server/server/vps-ssh.js +36 -7
  190. package/dist-server/server/workspace-backup-policy.js +12 -0
  191. package/dist-server/server/workspace-backup.js +42 -5
  192. package/dist-server/server/workspace-backup.worker.js +10 -0
  193. package/dist-server/server/workspace.js +1 -1
  194. package/dist-server/shared/approval-mode.js +1 -1
  195. package/dist-server/shared/ask-question.js +207 -12
  196. package/dist-server/shared/bot-profile.js +15 -0
  197. package/dist-server/shared/command-allowlist.js +1 -0
  198. package/dist-server/shared/credential-request.js +6 -2
  199. package/dist-server/shared/line-diff.js +23 -0
  200. package/dist-server/shared/model-request.js +1 -0
  201. package/dist-server/shared/options-card.js +51 -0
  202. package/dist-server/shared/package-format.js +967 -0
  203. package/dist-server/shared/profile-request.js +5 -4
  204. package/dist-server/shared/skill-md.js +64 -0
  205. package/dist-server/shared/team-backup.js +7 -0
  206. package/dist-server/shared/tightening-request.js +87 -0
  207. package/dist-server/shared/wire.js +4 -0
  208. package/dist-server/src/lib/composer-attachments.js +25 -3
  209. package/dist-server/vps-container-mcp.js +1132 -171
  210. package/dist-server/workspace-backup.worker.js +19474 -0
  211. package/enterprise/README.md +1 -1
  212. package/package.json +1 -1
  213. package/dist/assets/channel-BXFkgus9.js +0 -1
  214. package/dist/assets/index-BGgqlWPJ.css +0 -1
  215. package/dist/assets/index-BtHo8ZKl.js +0 -577
  216. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -26,16 +26,33 @@ import { join } from "node:path";
26
26
  import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv } from "../config.js";
27
27
  import { augmentedPath } from "../env-path.js";
28
28
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
29
+ import { deletePromptSplitReceipt, promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt, } from "./prompt-split.js";
29
30
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
30
31
  import { commandSummary, toolDetailPreview } from "../tool-summary.js";
31
32
  import { EFFORT_LEVELS } from "../../shared/wire.js";
32
33
  import { newEventId, newId } from "../contracts.js";
34
+ import { parseAskQuestions, parseChoices, questionAnswersByQuestion } from "../../shared/ask-question.js";
33
35
  import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
34
36
  import { appendNative } from "./native.js";
35
37
  const DRIVER_KIND = "piAgent";
36
38
  const PI_ARGS = ["--mode", "rpc", "--no-session"];
37
39
  const PI_MODEL_UPDATE_ARGS = ["update", "--models", "--no-approve"];
38
40
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
41
+ /** After this many bare turns on one pi session the full prompt rides
42
+ * again even without a compaction event: any history rewrite the events
43
+ * miss (an older pi, a missed line) still loses at most this many turns
44
+ * of standing instructions instead of the rest of the session. */
45
+ const PI_PROMPT_RE_ANCHOR_TURNS = 8;
46
+ /** How long the driver keeps listening after a finished turn_end for the
47
+ * run's terminal agent_end before settling anyway: a runtime that never
48
+ * emits agent_end still completes, while one that does cancels the wait. */
49
+ const PI_AGENT_END_GRACE_MS = 60_000;
50
+ /** pi answered a command with success:false — an explicit refusal from a
51
+ * live runtime, not a transport failure. Only steer branches on the
52
+ * difference today ("refused": provably not delivered, safe to re-queue);
53
+ * the handshake commands keep their catch-all ignore. */
54
+ class PiRpcRefusalError extends Error {
55
+ }
39
56
  function readPiPromptImages(turn) {
40
57
  return (turn.images ?? []).map((image) => ({
41
58
  type: "image",
@@ -425,7 +442,18 @@ export const PiDriver = {
425
442
  }
426
443
  const turnId = newId();
427
444
  const pending = new Map();
445
+ // Ask fail-safe timers, tracked so settle() can cancel them outright:
446
+ // a cleared pending map alone leaves each timer holding the ask
447
+ // closure (send, child) alive until it fires.
448
+ const askTimers = new Set();
428
449
  let settled = false;
450
+ // the last turn_end's outcome, replayed when the run's terminal
451
+ // agent_end (or the grace fallback) settles the turn
452
+ let endStopReason;
453
+ let endUsage;
454
+ let endErrorMessage;
455
+ // hang insurance for runtimes with no agent_end frame at all
456
+ let agentEndTimer = null;
429
457
  // pi's RPC surface accepts image content directly. Read before spawning
430
458
  // so an attachment that disappeared produces one clear dispatch error
431
459
  // instead of starting a child that can never receive its prompt.
@@ -488,8 +516,17 @@ export const PiDriver = {
488
516
  })();
489
517
  let buf = "";
490
518
  let assistantText = "";
519
+ // set when a compaction event arrives this turn; gates the receipt
520
+ // write below so a compacted-around delivery is never claimed
521
+ let compactionObserved = false;
491
522
  // resolve one-shot RPC responses (new_session / switch_session / set_model)
492
523
  const responseWaiters = new Map();
524
+ // Steer frames correlate by a unique per-request id, not the shared
525
+ // "steer" command name: two concurrent steers would otherwise overwrite
526
+ // each other's waiter, and a refusal response could reject the wrong
527
+ // caller — requeueing words pi already accepted. Every other command
528
+ // keeps its command-name key.
529
+ let steerSeq = 0;
493
530
  const rejectWaiters = (err) => {
494
531
  for (const waiter of responseWaiters.values()) {
495
532
  clearTimeout(waiter.timer);
@@ -497,13 +534,13 @@ export const PiDriver = {
497
534
  }
498
535
  responseWaiters.clear();
499
536
  };
500
- const awaitResponse = (command, timeoutMs = 20_000) => new Promise((resolve, reject) => {
537
+ const awaitResponse = (command, timeoutMs = 20_000, key = command) => new Promise((resolve, reject) => {
501
538
  const timer = setTimeout(() => {
502
- responseWaiters.delete(command);
539
+ responseWaiters.delete(key);
503
540
  reject(new Error(`pi ${command} timed out`));
504
541
  }, timeoutMs);
505
542
  timer.unref?.();
506
- responseWaiters.set(command, { resolve, reject, timer });
543
+ responseWaiters.set(key, { resolve, reject, timer });
507
544
  });
508
545
  child.stdin.on("error", () => rejectWaiters(new Error("pi stdin closed")));
509
546
  const send = (obj) => {
@@ -522,6 +559,17 @@ export const PiDriver = {
522
559
  if (settled)
523
560
  return;
524
561
  settled = true;
562
+ if (agentEndTimer) {
563
+ clearTimeout(agentEndTimer);
564
+ agentEndTimer = null;
565
+ }
566
+ // The turn is over: drop unanswered asks so their 15-minute
567
+ // fail-safe timers are cancelled outright instead of no-oping on a
568
+ // dead child while holding the ask closure alive.
569
+ for (const timer of askTimers)
570
+ clearTimeout(timer);
571
+ askTimers.clear();
572
+ pending.clear();
525
573
  flushAssistantText();
526
574
  emit({
527
575
  ...base(threadId, turnId),
@@ -552,6 +600,23 @@ export const PiDriver = {
552
600
  }
553
601
  active.delete(threadId);
554
602
  };
603
+ /** Settle from the last turn_end's recorded outcome. Errors surface
604
+ * here, at run completion, so a failed run reports exactly once and
605
+ * only after any scheduled maintenance events had their chance to
606
+ * arrive. */
607
+ const finishRun = () => {
608
+ const sr = endStopReason;
609
+ if (sr === "error" || sr === "failed") {
610
+ emit({
611
+ ...base(threadId, turnId),
612
+ type: "runtime.error",
613
+ message: String(endErrorMessage ?? "pi turn failed").slice(0, 2_000),
614
+ });
615
+ settle(false, "failed", endUsage);
616
+ return;
617
+ }
618
+ settle(true, sr === "cancelled" || sr === "aborted" ? "cancelled" : "end_turn", endUsage);
619
+ };
555
620
  const stop = () => {
556
621
  try {
557
622
  send({ type: "abort" });
@@ -567,19 +632,53 @@ export const PiDriver = {
567
632
  }
568
633
  settle(true, "cancelled");
569
634
  };
570
- active.set(threadId, { stop, turnId, pending, child });
635
+ // Mid-turn input rides pi's native steer frame — the runtime queues
636
+ // it into the running agent without aborting the in-flight step, and
637
+ // answers success only after session.steer() accepted it. A success:
638
+ // false response is an explicit refusal (nothing consumed the words:
639
+ // safe to re-queue), while a death or timeout after the frame was
640
+ // written leaves the outcome unknowable — those are "indeterminate"
641
+ // so the caller can never run the words twice. Each frame carries a
642
+ // unique id that pi echoes on its response, so concurrent steers on
643
+ // the same turn never share or overwrite a waiter.
644
+ const steerActiveTurn = async (text) => {
645
+ if (settled || child.exitCode !== null || child.signalCode !== null)
646
+ return "refused";
647
+ let ack;
648
+ try {
649
+ const id = `steer:${++steerSeq}`;
650
+ ack = awaitResponse("steer", 20_000, id);
651
+ send({ type: "steer", id, message: text });
652
+ }
653
+ catch {
654
+ // stdin closed before the frame was written: provably undelivered.
655
+ return "refused";
656
+ }
657
+ try {
658
+ await ack;
659
+ return "steered";
660
+ }
661
+ catch (error) {
662
+ return error instanceof PiRpcRefusalError ? "refused" : "indeterminate";
663
+ }
664
+ };
665
+ active.set(threadId, { stop, turnId, pending, child, steer: steerActiveTurn });
571
666
  const onEvent = (evt) => {
572
667
  appendNative(threadId, { dir: "in", source: "pi.rpc", msg: evt });
573
668
  switch (evt.type) {
574
669
  case "response": {
575
- if (evt.command && responseWaiters.has(evt.command)) {
576
- const waiter = responseWaiters.get(evt.command);
577
- responseWaiters.delete(evt.command);
670
+ // Resolve the echoed request id first (unique per steer);
671
+ // fall back to the command name for the handshake-style call
672
+ // sites, whose responses carry no id.
673
+ const key = typeof evt.id === "string" && responseWaiters.has(evt.id) ? evt.id : evt.command;
674
+ if (key && responseWaiters.has(key)) {
675
+ const waiter = responseWaiters.get(key);
676
+ responseWaiters.delete(key);
578
677
  clearTimeout(waiter.timer);
579
678
  if (evt.success)
580
679
  waiter.resolve(evt.data);
581
680
  else
582
- waiter.reject(new Error(`pi ${evt.command} failed`));
681
+ waiter.reject(new PiRpcRefusalError(`pi ${evt.command} failed`));
583
682
  }
584
683
  return;
585
684
  }
@@ -626,48 +725,158 @@ export const PiDriver = {
626
725
  if (evt.method === "select" || evt.method === "confirm" || evt.method === "input") {
627
726
  flushAssistantText();
628
727
  const reqId = evt.id ?? newId();
629
- const isQuestion = evt.method === "input";
728
+ const isSelect = evt.method === "select";
729
+ const isQuestion = isSelect || evt.method === "input";
730
+ const selectOptions = isSelect && Array.isArray(evt.options)
731
+ ? evt.options.filter((option) => typeof option === "string") : [];
732
+ const summary = String(evt.title ?? (isQuestion ? "pi has a question" : "pi wants confirmation")).slice(0, 200);
733
+ // A select is a question with named options; the structured
734
+ // card renders from it while the flat choices keep older
735
+ // clients answering. An input has nothing to pick from and
736
+ // stays the free-text question it always was.
737
+ const question = isSelect
738
+ ? (parseAskQuestions({
739
+ questions: [{ question: summary, options: selectOptions }],
740
+ }) ?? [])[0]
741
+ : undefined;
742
+ const choices = question?.options.length ? question.options.map((option) => option.label) : undefined;
743
+ let timer;
630
744
  // Register BEFORE emitting: the harness may auto-approve from
631
745
  // inside its synchronous request.opened listener. Emitting first
632
746
  // made respondToRequest see no pending ask, return unavailable,
633
747
  // then fall back to a human card on every "Always allow" call.
634
748
  pending.set(reqId, (decision) => {
749
+ if (timer) {
750
+ clearTimeout(timer);
751
+ askTimers.delete(timer);
752
+ }
635
753
  if (decision.behavior === "deny")
636
754
  send({ type: "extension_ui_response", id: reqId, cancelled: true });
637
- else if (isQuestion)
638
- send({ type: "extension_ui_response", id: reqId, value: decision.message ?? "" });
755
+ else if (isQuestion) {
756
+ // Recover the picked label from a structured card's Q:/A:
757
+ // reply; a flat answer or typed text passes through
758
+ // verbatim (questionAnswersByQuestion's single-question
759
+ // fallback does exactly that).
760
+ const value = question
761
+ ? questionAnswersByQuestion(decision.message ?? "", [question])[question.question] ?? decision.message ?? ""
762
+ : decision.message ?? "";
763
+ // Display labels are capped/trimmed; pi expects the
764
+ // original option. Never guess if two normalize alike.
765
+ const matched = selectOptions.filter(option => parseChoices([option], 1)?.[0] === value);
766
+ send(matched.length > 1
767
+ ? { type: "extension_ui_response", id: reqId, cancelled: true }
768
+ : { type: "extension_ui_response", id: reqId, value: matched[0] ?? value });
769
+ }
639
770
  else
640
771
  send({ type: "extension_ui_response", id: reqId, confirmed: true });
641
772
  });
773
+ // The ask must never hold the turn forever: cancel it after 15
774
+ // minutes. The pending.delete guard makes this a no-op once the
775
+ // turn settled (settle clears pending) or a person answered.
776
+ timer = setTimeout(() => {
777
+ if (timer)
778
+ askTimers.delete(timer);
779
+ if (!pending.delete(reqId))
780
+ return;
781
+ send({ type: "extension_ui_response", id: reqId, cancelled: true });
782
+ emit({
783
+ ...base(threadId, turnId),
784
+ requestId: reqId,
785
+ type: "request.resolved",
786
+ behavior: "deny",
787
+ source: "timeout",
788
+ });
789
+ }, 15 * 60_000);
790
+ askTimers.add(timer);
791
+ timer.unref?.();
642
792
  emit({
643
793
  ...base(threadId, turnId),
644
794
  requestId: reqId,
645
795
  type: "request.opened",
646
796
  requestType: isQuestion ? "question" : "permission",
647
797
  tool: String(evt.title ?? "pi"),
648
- summary: String(evt.title ?? "pi wants confirmation"),
798
+ summary,
799
+ ...(choices ? { choices } : {}),
800
+ ...(question ? { questions: [question] } : {}),
649
801
  });
650
802
  }
651
803
  return;
652
804
  }
805
+ case "compaction_start":
806
+ case "compaction_end":
807
+ case "auto_compaction_start":
808
+ case "auto_compaction_end": {
809
+ // pi compaction (manual or automatic) summarizes older
810
+ // messages, which can absorb the turn that carried this
811
+ // session's standing prompt. Both events ride the RPC surface
812
+ // for every compaction kind, so the moment either arrives the
813
+ // receipt is dropped and the next turn re-establishes the full
814
+ // prompt. The rest of the compacting turn may still answer
815
+ // without its standing instructions; that single turn is the
816
+ // unavoidable cost of summarizing history mid-session.
817
+ if (sessionReady && sessionFile) {
818
+ compactionObserved = true;
819
+ deletePromptSplitReceipt("pi", JSON.stringify([threadId, sessionFile]));
820
+ }
821
+ return;
822
+ }
653
823
  case "turn_end":
654
- case "agent_end": {
655
- const sr = evt.message?.stopReason;
656
- // toolUse means pi ran a tool and auto-continues next turn to
657
- // answer — settling now would drop the final reply.
658
- if (sr === "toolUse" || sr === "tool_use" || sr === "tool_calls")
824
+ {
825
+ // a killed child can still flush one buffered frame after the
826
+ // turn settled (the abort path's cancelled turn_end is the
827
+ // common one); arming the grace timer then would leak it past
828
+ // a turn that is already complete.
829
+ if (settled)
830
+ return;
831
+ const sr = evt.message?.stopReason;
832
+ // toolUse means pi ran a tool and auto-continues next turn to
833
+ // answer — the run is not over, and the next turn_end carries
834
+ // the real outcome.
835
+ if (sr === "toolUse" || sr === "tool_use" || sr === "tool_calls")
836
+ return;
837
+ endStopReason = sr;
838
+ endUsage = evt.usage ?? evt.message?.usage;
839
+ endErrorMessage = evt.message?.errorMessage;
840
+ // agent_end, not turn_end, closes the run: pi can schedule
841
+ // post-run maintenance (overflow-recovery compaction) after
842
+ // the final turn_end, and only the terminal agent_end proves
843
+ // the session is done rewriting itself. The grace timer
844
+ // settles a runtime that never sends agent_end at all.
845
+ if (agentEndTimer)
846
+ clearTimeout(agentEndTimer);
847
+ agentEndTimer = setTimeout(finishRun, PI_AGENT_END_GRACE_MS);
848
+ agentEndTimer.unref?.();
659
849
  return;
660
- const usage = evt.usage ?? evt.message?.usage;
661
- if (sr === "error" || sr === "failed") {
662
- emit({
663
- ...base(threadId, turnId),
664
- type: "runtime.error",
665
- message: String(evt.message?.errorMessage ?? "pi turn failed").slice(0, 2_000),
666
- });
667
- settle(false, "failed", usage);
850
+ }
851
+ case "agent_end": {
852
+ // Two dialects mark a non-terminal agent_end: upstream pi sets
853
+ // willRetry: true when an automatic retry follows (overflow
854
+ // recovery runs compaction and retries as a fresh run), and the
855
+ // omp fork sets isTerminal: false while maintenance or async
856
+ // delivery has more work scheduled. Either marker means the
857
+ // session may still rewrite the turn that carried this
858
+ // session's standing prompt — keep listening so those events
859
+ // can drop the prompt-split receipt.
860
+ if (evt.isTerminal === false || evt.willRetry === true)
668
861
  return;
862
+ if (agentEndTimer) {
863
+ clearTimeout(agentEndTimer);
864
+ agentEndTimer = null;
865
+ }
866
+ finishRun();
867
+ return;
868
+ }
869
+ case "agent_settled": {
870
+ // upstream pi's explicit fully-settled guarantee: no automatic
871
+ // retry, compaction retry, or queued continuation remains.
872
+ // Usually settles one frame after the final agent_end already
873
+ // did; kept as its own terminal signal so the driver never
874
+ // depends on which dialect the configured cli speaks.
875
+ if (agentEndTimer) {
876
+ clearTimeout(agentEndTimer);
877
+ agentEndTimer = null;
669
878
  }
670
- settle(true, sr === "cancelled" || sr === "aborted" ? "cancelled" : "end_turn", usage);
879
+ finishRun();
671
880
  return;
672
881
  }
673
882
  default:
@@ -709,6 +918,7 @@ export const PiDriver = {
709
918
  // sessionFile, which switch_session expects as `sessionPath`.
710
919
  const sessionPath = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
711
920
  let sessionFile = sessionPath;
921
+ let sessionReady = false;
712
922
  try {
713
923
  const command = sessionPath ? "switch_session" : "new_session";
714
924
  const hsPromise = awaitResponse(command);
@@ -716,6 +926,7 @@ export const PiDriver = {
716
926
  const hs = (await hsPromise);
717
927
  if (hs?.sessionFile)
718
928
  sessionFile = hs.sessionFile;
929
+ sessionReady = true;
719
930
  emit({
720
931
  ...base(threadId, turnId),
721
932
  type: "session.started",
@@ -751,11 +962,71 @@ export const PiDriver = {
751
962
  /* keep going on the engine default */
752
963
  }
753
964
  }
754
- const message = turn.system ? `${turn.system}\n\n${turn.text}` : turn.text;
965
+ // The stable/volatile split: the full prompt rides only the turn that
966
+ // establishes - or re-instructs, after a soul edit - this pi session,
967
+ // which previously re-sent the whole system prompt on every turn and
968
+ // let it accumulate in the session file once per turn. pi compacts
969
+ // long sessions by summarizing older messages, which can absorb the
970
+ // turn that carried the prompt, so the driver watches pi's
971
+ // compaction events and drops the receipt the moment one arrives,
972
+ // and re-anchors the full prompt every PI_PROMPT_RE_ANCHOR_TURNS
973
+ // bare turns as a backstop for anything the events miss. Receipts
974
+ // are durable because the session file outlives both the per-turn
975
+ // child and this process. Without a session the prompt is the
976
+ // model's only context, so that turn keeps the full block and writes
977
+ // no receipt.
978
+ const halves = promptHalves(turn);
979
+ let message;
980
+ let pendingReceipt = null;
981
+ if (halves.stable !== null && sessionReady && sessionFile) {
982
+ const receiptKey = JSON.stringify([threadId, sessionFile]);
983
+ const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt("pi", receiptKey), turn.system, turn.text, Boolean(turn.mentionTurn), PI_PROMPT_RE_ANCHOR_TURNS);
984
+ message = composed.text;
985
+ pendingReceipt = { key: receiptKey, receipt: composed.receipt };
986
+ }
987
+ else {
988
+ message = turn.system ? `${turn.system}\n\n${turn.text}` : turn.text;
989
+ }
990
+ // events cannot interleave with this synchronous stretch, so any
991
+ // compaction recorded above already invalidated what compose read
755
992
  try {
993
+ // pi answers the prompt with an authoritative response only after
994
+ // preflight accepts it - and rejects it outright while a
995
+ // compaction runs - so the receipt commits only after that
996
+ // acceptance: a rejected or compacted-around send redelivers on
997
+ // the next turn. The wait is long enough to survive a compaction
998
+ // queue; a dead child rejects it immediately.
999
+ // sendTurn still resolves once the prompt is written: a child that
1000
+ // dies before replying settles the turn through the close handler,
1001
+ // whose waiter sweep can precede this registration.
1002
+ const accepted = awaitResponse("prompt", 120_000);
756
1003
  send({ type: "prompt", message, ...(images.length ? { images } : {}) });
1004
+ void accepted.then(() => {
1005
+ if (!pendingReceipt || compactionObserved)
1006
+ return;
1007
+ try {
1008
+ writePromptSplitReceipt("pi", pendingReceipt.key, pendingReceipt.receipt);
1009
+ }
1010
+ catch {
1011
+ /* an unwritten receipt only re-delivers the full prompt next turn */
1012
+ }
1013
+ }, (err) => {
1014
+ if (settled)
1015
+ return;
1016
+ emit({
1017
+ ...base(threadId, turnId),
1018
+ type: "runtime.error",
1019
+ message: String(err?.message ?? err).slice(0, 2_000),
1020
+ });
1021
+ settle(false);
1022
+ });
757
1023
  }
758
- catch {
1024
+ catch (err) {
1025
+ emit({
1026
+ ...base(threadId, turnId),
1027
+ type: "runtime.error",
1028
+ message: String(err?.message ?? err).slice(0, 2_000),
1029
+ });
759
1030
  settle(false);
760
1031
  }
761
1032
  return { turnId };
@@ -828,6 +1099,9 @@ export const PiDriver = {
828
1099
  // xhigh/max only land on models that expose them; pi rejects an
829
1100
  // unsupported level and the turn keeps the engine default.
830
1101
  effortLevels: EFFORT_LEVELS,
1102
+ // pi's RPC mode takes a mid-turn steer frame the runtime queues
1103
+ // into the running agent without aborting the current step.
1104
+ queueing: true,
831
1105
  },
832
1106
  sendTurn,
833
1107
  interruptTurn: async (threadId) => active.get(threadId)?.stop(),
@@ -847,6 +1121,10 @@ export const PiDriver = {
847
1121
  });
848
1122
  return decision.behavior === "allow" ? "allowed-once" : decision.behavior === "answer" ? "answered" : "rejected";
849
1123
  },
1124
+ steer: async (threadId, text) => {
1125
+ const entry = active.get(threadId);
1126
+ return entry?.steer ? await entry.steer(text) : "refused";
1127
+ },
850
1128
  hasSession: (threadId) => active.has(threadId),
851
1129
  stopAll: async () => {
852
1130
  for (const { stop } of active.values())
@@ -0,0 +1,110 @@
1
+ // The stable/volatile prompt split, shared by the drivers that deliver it.
2
+ // The stable half is everything that must stay byte-identical for a
3
+ // provider's cached prefix (or a spawned CLI's session contract) to
4
+ // survive; the volatile half (memory, mentions, outstanding teammate
5
+ // work, recent work) legitimately changes mid-conversation and reaches
6
+ // the model inside the turn that changed it, after the cacheable prefix.
7
+ import { createHash } from "node:crypto";
8
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { DATA_DIR } from "../config.js";
11
+ import { writeFileAtomic } from "../atomic.js";
12
+ /** Read the split off a turn. Both halves must be present: a driver that
13
+ * receives only the unsplit system field was called through a path that
14
+ * never split it. */
15
+ export function promptHalves(turn) {
16
+ const { systemStable, systemVolatile } = turn;
17
+ if (typeof systemStable !== "string" || typeof systemVolatile !== "string") {
18
+ return { stable: null, volatile: "" };
19
+ }
20
+ return { stable: systemStable, volatile: systemVolatile };
21
+ }
22
+ export const VOLATILE_CONTEXT_NOTE_PREFIX = "Context from OpenMausBot updated since this conversation started; it replaces any earlier copy:";
23
+ export const VOLATILE_CONTEXT_CLEARED_NOTE = "The OpenMausBot context notes from earlier in this conversation (memory, mentions, outstanding teammate work) have been cleared; the standing instructions still apply.";
24
+ /** The labelled block that carries a changed volatile half inside a user
25
+ * turn. A half that is empty and always was needs no note; one that was
26
+ * cleared announces the removal so the model stops relying on it. */
27
+ export function volatileContextNote(volatile, hadVolatile) {
28
+ const text = volatile.trim();
29
+ if (text)
30
+ return VOLATILE_CONTEXT_NOTE_PREFIX + "\n\n" + text;
31
+ return hadVolatile ? VOLATILE_CONTEXT_CLEARED_NOTE : "";
32
+ }
33
+ /** Prepend a delivered note to the turn text: the one composition shape
34
+ * every split-aware driver uses. */
35
+ export function withContextNote(note, text) {
36
+ if (!note)
37
+ return text;
38
+ return text ? note + "\n\n" + text : note;
39
+ }
40
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
41
+ const EMPTY_FINGERPRINT = digest("");
42
+ export function promptSplitFingerprints(stable, volatile) {
43
+ return { stable: digest(stable), volatile: digest(volatile) };
44
+ }
45
+ const receiptPath = (scope, key) => join(DATA_DIR, "prompt-split", digest(JSON.stringify([scope, key])) + ".json");
46
+ /** Which halves a native session (an ACP session id, a pi session file)
47
+ * last carried. Unknown - never tracked, or tracked before this file
48
+ * existed - reads as null, and the caller re-delivers the full prompt
49
+ * once. */
50
+ export function readPromptSplitReceipt(scope, key) {
51
+ try {
52
+ const raw = JSON.parse(readFileSync(receiptPath(scope, key), "utf8"));
53
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
54
+ const record = raw;
55
+ if (typeof record.stable === "string" && typeof record.volatile === "string") {
56
+ return {
57
+ stable: record.stable,
58
+ volatile: record.volatile,
59
+ ...(typeof record.turnsSinceFull === "number" ? { turnsSinceFull: record.turnsSinceFull } : {}),
60
+ };
61
+ }
62
+ }
63
+ }
64
+ catch {
65
+ /* a missing or corrupt receipt is simply unknown */
66
+ }
67
+ return null;
68
+ }
69
+ export function writePromptSplitReceipt(scope, key, receipt) {
70
+ mkdirSync(join(DATA_DIR, "prompt-split"), { recursive: true });
71
+ writeFileAtomic(receiptPath(scope, key), JSON.stringify(receipt), { mode: 0o600 });
72
+ }
73
+ /** Drop a session's receipt: its history was rewritten (pi compaction
74
+ * summarizes older messages), so what it last carried is unknown and the
75
+ * next turn re-delivers the full prompt. A missing receipt already reads
76
+ * as unknown, so deleting one is always safe. */
77
+ export function deletePromptSplitReceipt(scope, key) {
78
+ try {
79
+ rmSync(receiptPath(scope, key));
80
+ }
81
+ catch {
82
+ /* a missing receipt is already unknown */
83
+ }
84
+ }
85
+ /** Compose the prompt for a session that persists its own history (ACP
86
+ * agents, pi): the full system block rides only when this native session
87
+ * has not carried it - or carried a different one, which re-delivers the
88
+ * current copy the way a pre-split turn always did - and a changed
89
+ * volatile half rides as a labelled note. Otherwise the turn text goes
90
+ * through bare, so an ordinary memory write neither appends a second copy
91
+ * of the prompt to the session nor re-prices its cached prefix.
92
+ * perTurnVolatile marks a turn whose volatile half describes this very
93
+ * turn (a mention): its note is delivered even when the text is unchanged.
94
+ * reAnchorAfterTurns, when positive, re-sends the full prompt after that
95
+ * many consecutive bare turns: a session that rewrites its own history
96
+ * without an observable event (an older pi, a missed compaction) still
97
+ * gets its standing instructions back within a bounded number of turns. */
98
+ export function splitSessionPrompt(stable, volatile, previous, fullSystem, text, perTurnVolatile = false, reAnchorAfterTurns = 0) {
99
+ const fingerprints = promptSplitFingerprints(stable, volatile);
100
+ // Legacy receipts predate the turn counter; count them from zero.
101
+ const turnsSinceFull = typeof previous?.turnsSinceFull === "number" ? previous.turnsSinceFull : 0;
102
+ const reAnchor = reAnchorAfterTurns > 0 && previous !== null && turnsSinceFull >= reAnchorAfterTurns;
103
+ if (previous === null || previous.stable !== fingerprints.stable || reAnchor) {
104
+ return { text: fullSystem ? fullSystem + "\n\n" + text : text, receipt: { ...fingerprints, turnsSinceFull: 0 } };
105
+ }
106
+ const note = previous.volatile === fingerprints.volatile && !perTurnVolatile
107
+ ? ""
108
+ : volatileContextNote(volatile, previous.volatile !== EMPTY_FINGERPRINT);
109
+ return { text: withContextNote(note, text), receipt: { ...fingerprints, turnsSinceFull: turnsSinceFull + 1 } };
110
+ }
@@ -0,0 +1,29 @@
1
+ // One session-idle policy for every pooled harness process. The Claude
2
+ // driver and the ACP core grew byte-identical copies of this computation
3
+ // with different env prefixes; the policy lives here so the default, the
4
+ // floor, and the env names cannot drift apart again.
5
+ const DEFAULT_IDLE_MS = 10 * 60_000;
6
+ const MINIMUM_IDLE_MS = 10_000;
7
+ /** setTimeout treats anything above 2^31-1 ms as 1 ms, so a larger delay
8
+ * would close the session immediately instead of much later. */
9
+ const MAX_TIMER_MS = 2_147_483_647;
10
+ function usableMilliseconds(raw) {
11
+ const value = Number(raw);
12
+ return Number.isFinite(value) && value > 0 && value <= MAX_TIMER_MS ? value : null;
13
+ }
14
+ /** The idle policy for one harness. The per-harness names that preceded the
15
+ * unified ones (OMB_CLAUDE_SESSION_IDLE_*, OMB_ACP_SESSION_IDLE_*) win when
16
+ * both are set, so a harness-specific override keeps working alongside a
17
+ * global OMB_SESSION_IDLE_MS / OMB_SESSION_IDLE_MIN_MS; the unified names
18
+ * are the default for harnesses that did not set their own. A delay that is
19
+ * not a positive finite number of at most 2^31-1 ms is validated away before
20
+ * the floor applies — the old arithmetic coerced Infinity and larger values
21
+ * into an immediately-firing 1 ms timer. The floor is the greater of the
22
+ * built-in ten seconds and any configured minimum, so a per-harness minimum
23
+ * can raise the floor but never lower it, and it applies to whatever delay
24
+ * wins. */
25
+ export function sessionIdlePolicy(driverPrefix, env = process.env) {
26
+ const minimumMs = Math.max(MINIMUM_IDLE_MS, usableMilliseconds(env[`OMB_${driverPrefix}_SESSION_IDLE_MIN_MS`] ?? env.OMB_SESSION_IDLE_MIN_MS) ?? 0);
27
+ const configured = usableMilliseconds(env[`OMB_${driverPrefix}_SESSION_IDLE_MS`] ?? env.OMB_SESSION_IDLE_MS) ?? DEFAULT_IDLE_MS;
28
+ return { idleMs: Math.max(minimumMs, configured), minimumMs };
29
+ }
@@ -41,6 +41,7 @@ function knownDirs() {
41
41
  join(home, ".volta", "bin"),
42
42
  join(home, ".bun", "bin"),
43
43
  join(home, ".asdf", "shims"),
44
+ join(home, ".local", "share", "mise", "shims"), // mise installer
44
45
  join(home, ".deno", "bin"),
45
46
  join(home, "bin"),
46
47
  ...nvmBinDirs(),
@@ -144,6 +145,20 @@ export function resetPathCacheForTests() {
144
145
  loginShellPath = null;
145
146
  registeredDirs.length = 0;
146
147
  }
148
+ /** The user's home directory as this platform defines it. Windows keeps the
149
+ * real profile in USERPROFILE; a HOME that leaks in from a POSIX-flavored
150
+ * shell is not where Windows CLIs keep their state, so USERPROFILE wins there
151
+ * and HOME wins everywhere else. */
152
+ export function userHome(env = process.env) {
153
+ return process.platform === "win32"
154
+ ? env.USERPROFILE || env.HOME || homedir()
155
+ : env.HOME || env.USERPROFILE || homedir();
156
+ }
157
+ /** `<user home>/.<name>` — the per-harness state directory every CLI keeps
158
+ * (`.qwen`, `.grok`, `.codex`…), Windows-correct everywhere. */
159
+ export function harnessHome(name, env = process.env) {
160
+ return join(userHome(env), `.${name}`);
161
+ }
147
162
  /** Every `name` binary on the augmented PATH as absolute paths, in PATH
148
163
  * order (first = what a bare name would run). Used by the Engines panel's
149
164
  * "detected" dropdown and the /api/cli-candidates endpoint. A path-ish