openmausbot 0.1.85 → 0.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/dist/assets/abnfDiagram-O67JEVCF-BTiuL79F.js +1 -0
  62. package/dist/assets/arc-C88Blc2v.js +1 -0
  63. package/dist/assets/architectureDiagram-NJMV4G6O-BZsJIZrb.js +36 -0
  64. package/dist/assets/blockDiagram-BEXU5L5S-CVeMe9Ah.js +140 -0
  65. package/dist/assets/c4Diagram-YGBWAQC7-Cedk3Bhx.js +38 -0
  66. package/dist/assets/channel-taCNRlUU.js +1 -0
  67. package/dist/assets/chunk-2Q5K7J3B-D-VRqzTB.js +1 -0
  68. package/dist/assets/chunk-3FUC2YCW-BChov01Y.js +2 -0
  69. package/dist/assets/chunk-5VM5RSS4-C-TfwvHy.js +15 -0
  70. package/dist/assets/chunk-GTNCS2PH-Db7CGj5Y.js +88 -0
  71. package/dist/assets/chunk-GWA4HPMP-CNvIfqKT.js +1 -0
  72. package/dist/assets/chunk-JWPE2WC7-C_bEp5tq.js +1 -0
  73. package/dist/assets/chunk-NETBCI7D-Coi4BOls.js +1 -0
  74. package/dist/assets/chunk-WEXAMYUT-CzylEuL3.js +1 -0
  75. package/dist/assets/chunk-XXDRQBXY-m7p7ztaD.js +1 -0
  76. package/dist/assets/classDiagram-v2-NBCMYWYE-BMy6o9x2.js +217 -0
  77. package/dist/assets/cose-bilkent-JH36ORCC-BDQ__0_J.js +1 -0
  78. package/dist/assets/cynefin-OW5HDTMX-Djv4ghUb.js +166 -0
  79. package/dist/assets/cynefinDiagram-VND7K2PF-BULrlGYH.js +62 -0
  80. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  81. package/dist/assets/dagre-6A5THRUB-D-jrT_hU.js +4 -0
  82. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  83. package/dist/assets/diagram-22UHCM2B-p2UhQmfL.js +200 -0
  84. package/dist/assets/diagram-3UASUU5V-DHvGWZ7q.js +24 -0
  85. package/dist/assets/diagram-ATOU4E4O-ChhKDot-.js +3 -0
  86. package/dist/assets/diagram-CDSNMT55-B-T7A43R.js +30 -0
  87. package/dist/assets/diagram-MLGK6HIB-CJm8ENNY.js +24 -0
  88. package/dist/assets/diagram-MPIPVDR6-510tlza8.js +41 -0
  89. package/dist/assets/ebnfDiagram-ZINNZB2B-PkvRPHel.js +1 -0
  90. package/dist/assets/elk-276RUBZZ-BVBVPoX3.js +27 -0
  91. package/dist/assets/erDiagram-OPXOYQCR-BgYMVm7L.js +99 -0
  92. package/dist/assets/flowDiagram-KWPJA3E3-BGqsnc_3.js +213 -0
  93. package/dist/assets/ganttDiagram-FUAMR5RP-D_-UeReH.js +292 -0
  94. package/dist/assets/gitGraphDiagram-X574FWY7-D0uooONx.js +106 -0
  95. package/dist/assets/graph-D8UmR1vK.js +1 -0
  96. package/dist/assets/index-BwdMAQ6p.css +1 -0
  97. package/dist/assets/index-ByJennr7.js +581 -0
  98. package/dist/assets/{index-CTNNoqSJ.js → index-zRsJERGJ.js} +1 -1
  99. package/dist/assets/infoDiagram-VRGFBTTK-B9e1z77f.js +2 -0
  100. package/dist/assets/init-Gi6I4Gst.js +1 -0
  101. package/dist/assets/ishikawaDiagram-OU5B5YK6-BB4vNwvD.js +70 -0
  102. package/dist/assets/journeyDiagram-ZHPQQLJL-DpkosOTB.js +139 -0
  103. package/dist/assets/kanban-definition-PNTS6WVX-DbDP4kUu.js +89 -0
  104. package/dist/assets/linear-DBJGPmjT.js +1 -0
  105. package/dist/assets/mermaid.core-DWPRi-ue.js +311 -0
  106. package/dist/assets/mindmap-definition-NLK3R4M7-nszBbJmx.js +96 -0
  107. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  108. package/dist/assets/pegDiagram-GJSIUBJH-CWykJWu_.js +1 -0
  109. package/dist/assets/pieDiagram-5QR66LMP-g3Da-Vnv.js +39 -0
  110. package/dist/assets/quadrantDiagram-O4NWA36T-Wzrlpzgh.js +7 -0
  111. package/dist/assets/railroadDiagram-XR7U4H2S-moHTFMC0.js +1 -0
  112. package/dist/assets/requirementDiagram-PLB6GJNP-CwfXdFH8.js +84 -0
  113. package/dist/assets/sankeyDiagram-IPEJSGJF-3ZROYvnq.js +40 -0
  114. package/dist/assets/sequenceDiagram-PO4LG4MO-E1TBebP8.js +169 -0
  115. package/dist/assets/sizeCapture-INFHLROL-DHKEU5pu.js +1 -0
  116. package/dist/assets/stateDiagram-v2-GCMORJYK-BguEj09D.js +291 -0
  117. package/dist/assets/swimlanes-2SLR337P-C7IDYnhd.js +1 -0
  118. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  119. package/dist/assets/timeline-definition-EJHVYXUP-DCW-F5PW.js +120 -0
  120. package/dist/assets/usecaseDiagram-POWQR4AR-xBMFtWYV.js +370 -0
  121. package/dist/assets/vennDiagram-UO4OBE2U-B5nIkuH8.js +34 -0
  122. package/dist/assets/wardleyDiagram-VNRHLVJA-DYFAhdbr.js +78 -0
  123. package/dist/assets/xychartDiagram-PMCCYNJV-DeXwX6Cz.js +7 -0
  124. package/dist/index.html +2 -2
  125. package/dist-server/browser-proxy.js +2 -1
  126. package/dist-server/container-mcp.js +1047 -173
  127. package/dist-server/drivers/agents-proxy.js +16121 -1504
  128. package/dist-server/drivers/phone-proxy.js +33 -2
  129. package/dist-server/enterprise/LICENSE +32 -0
  130. package/dist-server/enterprise/server/index.js +5 -5
  131. package/dist-server/index.js +16134 -7446
  132. package/dist-server/local-computer-proxy.js +3 -3
  133. package/dist-server/local-computer.js +53 -17
  134. package/dist-server/mcp-server.js +3 -1
  135. package/dist-server/openmausbot.js +9353 -6124
  136. package/dist-server/pair-cli.js +9353 -6124
  137. package/dist-server/permission-proxy.js +29 -9
  138. package/dist-server/server/admin-activity.js +410 -0
  139. package/dist-server/server/attachments.js +55 -0
  140. package/dist-server/server/auto-approve.js +4 -3
  141. package/dist-server/server/auto-vm-claims.js +1 -0
  142. package/dist-server/server/bot-package.js +18 -292
  143. package/dist-server/server/bot-profile.js +5 -17
  144. package/dist-server/server/bot-visibility.js +462 -0
  145. package/dist-server/server/box-create-idempotency.js +3 -3
  146. package/dist-server/server/box.js +59 -56
  147. package/dist-server/server/browser-proxy.js +3 -2
  148. package/dist-server/server/browser-runtime.js +38 -2
  149. package/dist-server/server/channel-queue.js +2 -0
  150. package/dist-server/server/cli-prompts.js +24 -2
  151. package/dist-server/server/cli.js +81 -12
  152. package/dist-server/server/command-allowlist.js +154 -0
  153. package/dist-server/server/composio.js +49 -11
  154. package/dist-server/server/computer-wait.js +22 -9
  155. package/dist-server/server/config.js +133 -17
  156. package/dist-server/server/connector-verdict.js +141 -0
  157. package/dist-server/server/container-computer.js +1 -1
  158. package/dist-server/server/decision-log.js +209 -48
  159. package/dist-server/server/default-model-selection.js +29 -3
  160. package/dist-server/server/delegations.js +97 -44
  161. package/dist-server/server/drivers/acp/core.js +163 -33
  162. package/dist-server/server/drivers/acp/grok.js +4 -3
  163. package/dist-server/server/drivers/agents-call.js +1162 -0
  164. package/dist-server/server/drivers/agents-catalog.js +689 -0
  165. package/dist-server/server/drivers/agents-client.js +28 -0
  166. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  167. package/dist-server/server/drivers/boxagent.js +174 -36
  168. package/dist-server/server/drivers/builtIn.js +2 -0
  169. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  170. package/dist-server/server/drivers/chat-images.js +54 -0
  171. package/dist-server/server/drivers/chat-mcp-tools.js +55 -10
  172. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  173. package/dist-server/server/drivers/claude.js +28 -8
  174. package/dist-server/server/drivers/codex-instructions.js +48 -6
  175. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  176. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  177. package/dist-server/server/drivers/codex.js +121 -34
  178. package/dist-server/server/drivers/grok.js +2 -1
  179. package/dist-server/server/drivers/mistral.js +115 -0
  180. package/dist-server/server/drivers/openai-chat.js +155 -32
  181. package/dist-server/server/drivers/openai-compat.js +16 -8
  182. package/dist-server/server/drivers/permission-command.js +35 -0
  183. package/dist-server/server/drivers/phone-proxy.js +47 -2
  184. package/dist-server/server/drivers/pi.js +75 -4
  185. package/dist-server/server/drivers/prompt-split.js +87 -0
  186. package/dist-server/server/enterprise.js +97 -16
  187. package/dist-server/server/external-runtime.js +83 -0
  188. package/dist-server/server/fleet.js +1 -1
  189. package/dist-server/server/group-thread-usage.js +103 -0
  190. package/dist-server/server/guarded-requests.js +60 -0
  191. package/dist-server/server/harness/http.js +61 -0
  192. package/dist-server/server/harness/registry.js +1 -0
  193. package/dist-server/server/hosted-models.js +151 -0
  194. package/dist-server/server/hosted-slack.js +11 -0
  195. package/dist-server/server/index.js +3091 -658
  196. package/dist-server/server/local-computer.js +72 -23
  197. package/dist-server/server/managed-desktop.js +79 -14
  198. package/dist-server/server/managed-policy.js +153 -0
  199. package/dist-server/server/mcp-bridge.js +6 -6
  200. package/dist-server/server/message-db.js +28 -0
  201. package/dist-server/server/model-context-window.js +7 -0
  202. package/dist-server/server/model-prices.js +121 -0
  203. package/dist-server/server/new-bot-defaults.js +115 -0
  204. package/dist-server/server/notify.js +10 -1
  205. package/dist-server/server/org-library.js +1051 -0
  206. package/dist-server/server/package-export.js +627 -39
  207. package/dist-server/server/package-import.js +458 -0
  208. package/dist-server/server/package-parts.js +87 -0
  209. package/dist-server/server/peer-approval.js +16 -7
  210. package/dist-server/server/peer-roster.js +23 -2
  211. package/dist-server/server/presets.js +492 -0
  212. package/dist-server/server/provider-key-check.js +3 -2
  213. package/dist-server/server/published-teams.js +80 -0
  214. package/dist-server/server/recent-work.js +15 -0
  215. package/dist-server/server/request-auth.js +96 -0
  216. package/dist-server/server/room-handoffs.js +44 -14
  217. package/dist-server/server/routes/bot-presets.js +25 -0
  218. package/dist-server/server/routes/hosted-slack.js +20 -0
  219. package/dist-server/server/routes/table.js +14 -0
  220. package/dist-server/server/routine-requests.js +6 -3
  221. package/dist-server/server/routines.js +63 -1
  222. package/dist-server/server/section-context.js +10 -0
  223. package/dist-server/server/skills.js +34 -61
  224. package/dist-server/server/spend.js +111 -16
  225. package/dist-server/server/steer-queue.js +9 -3
  226. package/dist-server/server/store.js +550 -35
  227. package/dist-server/server/surface.js +10 -4
  228. package/dist-server/server/system-prompt.js +12 -4
  229. package/dist-server/server/team-backup.js +16 -3
  230. package/dist-server/server/team-computers.js +10 -0
  231. package/dist-server/server/team-library.js +6 -2
  232. package/dist-server/server/team-manifest.js +4 -3
  233. package/dist-server/server/team-setup-requests.js +16 -5
  234. package/dist-server/server/thread-events.js +17 -1
  235. package/dist-server/server/thread-starters.js +53 -0
  236. package/dist-server/server/turn-context.js +2 -0
  237. package/dist-server/server/turn-watchdog.js +12 -3
  238. package/dist-server/server/usage-ledger.js +43 -9
  239. package/dist-server/server/vps-computer.js +139 -51
  240. package/dist-server/server/vps-container-mcp.js +7 -0
  241. package/dist-server/server/vps-ssh.js +36 -7
  242. package/dist-server/server/workspace-backup-policy.js +21 -2
  243. package/dist-server/server/workspace-backup.js +7 -4
  244. package/dist-server/server/workspace.js +1 -1
  245. package/dist-server/shared/approval-mode.js +1 -1
  246. package/dist-server/shared/ask-question.js +188 -12
  247. package/dist-server/shared/bot-profile.js +15 -0
  248. package/dist-server/shared/command-allowlist.js +1 -0
  249. package/dist-server/shared/credential-request.js +1 -1
  250. package/dist-server/shared/mention-boundary.js +15 -0
  251. package/dist-server/shared/package-format.js +967 -0
  252. package/dist-server/shared/routines.js +7 -1
  253. package/dist-server/shared/skill-md.js +64 -0
  254. package/dist-server/shared/team-backup.js +9 -0
  255. package/dist-server/shared/wire.js +4 -0
  256. package/dist-server/src/lib/composer-attachments.js +16 -0
  257. package/dist-server/vps-container-mcp.js +1147 -179
  258. package/enterprise/README.md +33 -12
  259. package/enterprise/server/index.js +5 -5
  260. package/package.json +1 -1
  261. package/dist/assets/index-Ba9G44HI.js +0 -310
  262. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -1,7 +1,10 @@
1
1
  import { newEventId, newId } from "../contracts.js";
2
+ import { ASK_USER_TOOL, ASK_USER_TOOL_DEFINITION, askQuestionSummary, parseAskQuestions, questionChoices } from "../../shared/ask-question.js";
2
3
  import { redactSecretsInText } from "../redact.js";
3
4
  import { toolDetailPreview } from "../tool-summary.js";
4
5
  import { ChatToolSessionError, mountChatTools } from "./chat-mcp-tools.js";
6
+ import { assertImageTransport, chatImageBudget, chatToolImages, chatUserContent } from "./chat-images.js";
7
+ import { promptHalves, volatileContextNote, withContextNote } from "./prompt-split.js";
5
8
  import { createChatToolApproval } from "./chat-tool-approval.js";
6
9
  import { ChatProtocolError, ChatReasoningDetails, ChatToolCalls, MAX_CHAT_TOOL_CALLS, object } from "./openai-chat-protocol.js";
7
10
  import { appendNative } from "./native.js";
@@ -25,6 +28,26 @@ const usageFrom = (usage) => usage
25
28
  ? { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0 }
26
29
  : null;
27
30
  const asError = (value) => value instanceof Error ? value : new Error(String(value));
31
+ class UnsupportedChatToolsError extends Error {
32
+ }
33
+ function rejectsToolsParameter(status, body) {
34
+ if (status !== 400 && status !== 422)
35
+ return false;
36
+ let envelope;
37
+ try {
38
+ envelope = object(JSON.parse(body));
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ const error = object(envelope?.error);
44
+ if (error?.param === "tools" && error.code === "unsupported_parameter")
45
+ return true;
46
+ const message = error?.message ?? envelope?.error;
47
+ return typeof message === "string" && (/\bdoes not support (?:tools|tool calling|function calling)(?:[.!]?$|[.!]?\s)/i.test(message) ||
48
+ /\b(?:tools|tool calling|function calling) (?:is|are) not supported\b/i.test(message) ||
49
+ /^(?:unsupported|unknown|unrecognized) (?:parameter|field):?\s*['"]?tools['"]?[.!]?$/i.test(message.trim()));
50
+ }
28
51
  /** Shared runtime for the three providers that speak OpenAI chat completions. */
29
52
  export function createOpenAIChatRuntime(options) {
30
53
  const { input } = options;
@@ -59,6 +82,7 @@ export function createOpenAIChatRuntime(options) {
59
82
  : timeoutController.signal;
60
83
  const response = await fetch(`${options.apiUrl}/chat/completions`, {
61
84
  method: "POST",
85
+ ...(messages.some(message => Array.isArray(message.content) && message.content.some(part => part.type === "image_url")) ? { redirect: "error" } : {}),
62
86
  headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
63
87
  body: JSON.stringify({
64
88
  ...options.requestBody(model, messages, stream),
@@ -68,7 +92,10 @@ export function createOpenAIChatRuntime(options) {
68
92
  });
69
93
  if (!response.ok) {
70
94
  const body = await response.text().catch(() => "");
71
- throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
95
+ const message = `${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`;
96
+ if (rejectsToolsParameter(response.status, body))
97
+ throw new UnsupportedChatToolsError(message);
98
+ throw new Error(message);
72
99
  }
73
100
  if (!stream || response.headers.get("content-type")?.includes("application/json")) {
74
101
  const json = await response.json();
@@ -89,7 +116,7 @@ export function createOpenAIChatRuntime(options) {
89
116
  const finishReason = json.choices?.[0]?.finish_reason ?? null;
90
117
  activeSignal.throwIfAborted();
91
118
  return {
92
- text: typeof message?.content === "string" ? message.content : "",
119
+ text: options.contentText ? options.contentText(message?.content) : typeof message?.content === "string" ? message.content : "",
93
120
  reasoning: options.reasoning && typeof reasoning === "string"
94
121
  ? reasoning
95
122
  : "",
@@ -155,7 +182,7 @@ export function createOpenAIChatRuntime(options) {
155
182
  const reasoningDelta = options.reasoning && typeof reasoningPart === "string"
156
183
  ? reasoningPart
157
184
  : "";
158
- const contentDelta = typeof delta?.content === "string" ? delta.content : "";
185
+ const contentDelta = options.contentText ? options.contentText(delta?.content) : typeof delta?.content === "string" ? delta.content : "";
159
186
  if (reasoningDelta) {
160
187
  reasoning += reasoningDelta;
161
188
  onDelta?.(reasoningDelta, "reasoning_text");
@@ -220,24 +247,44 @@ export function createOpenAIChatRuntime(options) {
220
247
  clearTimeout(idleTimer);
221
248
  }
222
249
  };
223
- const messagesFor = (turn) => [
224
- ...(turn.system ? [{ role: "system", content: turn.system }] : []),
225
- ...(turn.transcript ?? []).map((message) => ({
226
- role: message.role,
227
- content: message.text,
228
- })),
229
- { role: "user", content: turn.text },
230
- ];
250
+ const messagesFor = (turn) => {
251
+ // The system message is the head of the resent prefix, so only the
252
+ // stable half belongs there: a volatile edit must not re-price the
253
+ // tools, instructions and transcript the provider already cached.
254
+ // The volatile half rides the newest user message instead, every
255
+ // turn. Unlike a CLI session, this request is rebuilt from the stored
256
+ // transcript, which never contains the delivered notes, so tracking a
257
+ // digest and delivering only on change would leave the model without
258
+ // its memory on unchanged turns. The newest message is fresh input
259
+ // on every request anyway.
260
+ const halves = promptHalves(turn);
261
+ const note = halves.stable !== null ? volatileContextNote(halves.volatile, false) : "";
262
+ const userTurn = note ? { ...turn, text: withContextNote(note, turn.text) } : turn;
263
+ const system = halves.stable ?? turn.system;
264
+ return [
265
+ ...(system ? [{ role: "system", content: system }] : []),
266
+ ...(turn.transcript ?? []).map((message) => ({
267
+ role: message.role,
268
+ content: message.text,
269
+ })),
270
+ { role: "user", content: options.computerUse ? chatUserContent(userTurn) : userTurn.text },
271
+ ];
272
+ };
231
273
  const sendTurn = async (turn) => {
232
274
  if (!options.apiKey)
233
275
  throw new Error(options.missingKeyError);
234
276
  if (active.has(turn.threadId))
235
277
  throw new Error("a turn is already running on this thread");
278
+ if (options.computerUse && (turn.images?.length || turn.integrations?.computer || turn.integrations?.localComputer || turn.integrations?.browser))
279
+ assertImageTransport(options.apiUrl);
236
280
  const turnId = newId();
237
281
  const abort = new AbortController();
238
282
  const messages = messagesFor(turn);
283
+ const retainImages = chatImageBudget();
284
+ for (const message of messages)
285
+ retainImages(message.content);
239
286
  const model = turn.model || options.models().default;
240
- const secrets = [options.apiKey];
287
+ const secrets = [options.apiKey, turn.integrations?.computer?.token, turn.integrations?.computer?.control?.token].filter((value) => Boolean(value));
241
288
  for (const integration of Object.values(turn.integrations ?? {})) {
242
289
  const entries = object(integration);
243
290
  const specs = entries && "command" in entries ? [entries] : Object.values(entries ?? {}).map(object);
@@ -270,6 +317,18 @@ export function createOpenAIChatRuntime(options) {
270
317
  ...base(turn.threadId, turnId), type: "request.resolved", requestId: ask.id,
271
318
  behavior: allowed ? "allow" : "deny", source,
272
319
  }),
320
+ openQuestion: (ask, questions) => {
321
+ const choices = questionChoices(questions);
322
+ emit({
323
+ ...base(turn.threadId, turnId), type: "request.opened", requestType: "question",
324
+ requestId: ask.id, tool: ask.tool, summary: ask.summary,
325
+ questions, ...(choices ? { choices } : {}),
326
+ });
327
+ },
328
+ resolvedQuestion: (ask, answered, source) => emit({
329
+ ...base(turn.threadId, turnId), type: "request.resolved", requestId: ask.id,
330
+ behavior: answered ? "answer" : "deny", source,
331
+ }),
273
332
  });
274
333
  let resolveDone;
275
334
  const done = new Promise((resolve) => { resolveDone = resolve; });
@@ -287,7 +346,16 @@ export function createOpenAIChatRuntime(options) {
287
346
  const denials = [];
288
347
  const seenCalls = new Set();
289
348
  try {
290
- tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal);
349
+ tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal, options.computerUse);
350
+ let optionalQuestionOnly = options.tools !== false && tools.definitions.length === 0;
351
+ // The runtime's one built-in tool rides the same list: ask_user is
352
+ // how a chat-completions engine reaches a person. An MCP server that
353
+ // squats the name cannot shadow it — dispatch intercepts the name
354
+ // before validate — but the definition is then skipped so the list
355
+ // never advertises two.
356
+ if (options.tools !== false && !tools.definitions.some((definition) => definition.function.name === ASK_USER_TOOL)) {
357
+ tools.definitions.push(ASK_USER_TOOL_DEFINITION);
358
+ }
291
359
  for (let round = 0; round < 16; round++) {
292
360
  abort.signal.throwIfAborted();
293
361
  native("out", options.nativeLog.outgoing(turn, messages, model));
@@ -332,6 +400,15 @@ export function createOpenAIChatRuntime(options) {
332
400
  catch (value) {
333
401
  const error = asError(value);
334
402
  const verdict = classifyError(error);
403
+ // Only our optional question changed a previously plain request.
404
+ // A structured parameter rejection has executed nothing; never
405
+ // downgrade mounted tools, streamed output or a handled call.
406
+ if (optionalQuestionOnly && round === 0 && !streamed && !seenCalls.size && !abort.signal.aborted &&
407
+ error instanceof UnsupportedChatToolsError && verdict.reason === "invalid_request") {
408
+ optionalQuestionOnly = false;
409
+ tools.definitions.length = 0;
410
+ continue;
411
+ }
335
412
  // Once a call has been handled, never replay it through a turn retry.
336
413
  if (options.retryScale === undefined || abort.signal.aborted || streamed || seenCalls.size ||
337
414
  error instanceof ChatProtocolError || !verdict.transient || attempt >= RETRY_MAX_ATTEMPTS - 1)
@@ -381,6 +458,7 @@ export function createOpenAIChatRuntime(options) {
381
458
  ...(completion.protocolReasoning ? { reasoning_content: completion.protocolReasoning } : {}),
382
459
  ...(completion.protocolReasoningDetails.length ? { reasoning_details: completion.protocolReasoningDetails } : {}),
383
460
  });
461
+ const screenshotParts = [];
384
462
  for (const call of completion.toolCalls) {
385
463
  abort.signal.throwIfAborted();
386
464
  let result;
@@ -396,26 +474,66 @@ export function createOpenAIChatRuntime(options) {
396
474
  }
397
475
  if (!object(args))
398
476
  throw new ChatProtocolError("tool arguments must be a JSON object");
399
- tools.validate(call.function.name, args);
400
477
  const inputPreview = preview(args);
401
- // Full access is the person's explicit grant to answer every
402
- // prompt. This runtime has no provider reviewer to hand it to,
403
- // so it is honoured here: without it every single tool call on
404
- // an OpenAI-compatible engine stops for a card, and a Chief's
405
- // delegated Full access cannot help either.
406
- const allowed = turn.approvalMode === "full"
407
- || await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
408
- abort.signal.throwIfAborted();
409
- emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
410
- title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
411
- });
412
- started = true;
413
- if (allowed) {
414
- result = await tools.execute(call.function.name, args, abort.signal);
478
+ if (call.function.name === ASK_USER_TOOL) {
479
+ // A question is the person's card, not a permission, so it is
480
+ // handled before the gate below: under Full access that gate
481
+ // would auto-run an unanswered ask, and under Ask it would
482
+ // render Allow/Deny over a question nobody can answer that way.
483
+ const questions = parseAskQuestions(args);
484
+ abort.signal.throwIfAborted();
485
+ emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
486
+ title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
487
+ });
488
+ started = true;
489
+ if (!questions) {
490
+ denials.push(ASK_USER_TOOL);
491
+ result = { ok: false, text: "The ask_user arguments are malformed: pass a JSON object with a questions array of one to six questions, each with a question string and at most twelve options carrying a label. Ask again with valid arguments." };
492
+ }
493
+ else {
494
+ // The tool result is the card's Q:/A: reply verbatim — never
495
+ // a summary, and never words the person did not send.
496
+ const answer = await approval.question(ASK_USER_TOOL, askQuestionSummary(questions), questions);
497
+ abort.signal.throwIfAborted();
498
+ if (answer === null) {
499
+ denials.push(ASK_USER_TOOL);
500
+ result = { ok: false, text: "The person did not answer this question. Do not guess an answer; ask again later or proceed without it." };
501
+ }
502
+ else {
503
+ result = { ok: true, text: answer };
504
+ }
505
+ }
415
506
  }
416
507
  else {
417
- denials.push(call.function.name);
418
- result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
508
+ tools.validate(call.function.name, args);
509
+ // Full access is the person's explicit grant to answer every
510
+ // prompt. This runtime has no provider reviewer to hand it to,
511
+ // so it is honoured here: without it every single tool call on
512
+ // an OpenAI-compatible engine stops for a card, and a Chief's
513
+ // delegated Full access cannot help either.
514
+ const allowed = turn.approvalMode === "full"
515
+ || await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
516
+ abort.signal.throwIfAborted();
517
+ emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
518
+ title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
519
+ });
520
+ started = true;
521
+ if (allowed) {
522
+ result = await tools.execute(call.function.name, args, abort.signal);
523
+ if (result.images?.length) {
524
+ try {
525
+ assertImageTransport(options.apiUrl);
526
+ retainImages(result.images);
527
+ }
528
+ catch (error) {
529
+ throw new ChatToolSessionError(asError(error).message);
530
+ }
531
+ }
532
+ }
533
+ else {
534
+ denials.push(call.function.name);
535
+ result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
536
+ }
419
537
  }
420
538
  }
421
539
  catch (error) {
@@ -433,10 +551,13 @@ export function createOpenAIChatRuntime(options) {
433
551
  if (!result.ok)
434
552
  toolFailed = true;
435
553
  messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ ok: result.ok, result: text }) });
554
+ screenshotParts.push(...chatToolImages(call.id, result.images));
436
555
  abort.signal.throwIfAborted();
437
556
  if (fatal)
438
557
  throw fatal;
439
558
  }
559
+ if (screenshotParts.length)
560
+ messages.push({ role: "user", content: screenshotParts });
440
561
  }
441
562
  if (!ok)
442
563
  throw new ChatProtocolError("model-call limit reached before a final response");
@@ -488,7 +609,9 @@ export function createOpenAIChatRuntime(options) {
488
609
  : { state: "unavailable", reason: options.unavailableReason },
489
610
  adapter: {
490
611
  provider: options.driverKind,
491
- capabilities: { sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false },
612
+ capabilities: { ...(options.computerUse ? { computerMcp: options.tools !== false, cloudComputerMcp: options.tools !== false, localComputerMcp: options.tools !== false,
613
+ browserMcp: options.tools !== false, nativeImageInput: true, images: true } : {}),
614
+ sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false },
492
615
  sendTurn,
493
616
  interruptTurn: async (threadId, turnId) => {
494
617
  const turn = active.get(threadId);
@@ -497,7 +620,7 @@ export function createOpenAIChatRuntime(options) {
497
620
  turn.abort.abort();
498
621
  await turn.done;
499
622
  },
500
- respondToRequest: async (threadId, requestId, decision) => active.get(threadId)?.approval.answer(requestId, decision.behavior) ?? "unavailable",
623
+ respondToRequest: async (threadId, requestId, decision) => active.get(threadId)?.approval.answer(requestId, decision.behavior, decision.message) ?? "unavailable",
501
624
  hasSession: (threadId) => active.has(threadId),
502
625
  stopAll: async () => {
503
626
  const turns = [...active.values()];
@@ -28,9 +28,12 @@ function decodeConfig(raw) {
28
28
  const config = (raw ?? {});
29
29
  if (config.tools !== undefined && typeof config.tools !== "boolean")
30
30
  throw new Error("tools must be a boolean");
31
+ if (config.managedModels !== undefined && (!Array.isArray(config.managedModels) || !config.managedModels.length || config.managedModels.some(model => typeof model !== "string" || !model.trim())))
32
+ throw new Error("Invalid managed models.");
31
33
  const envUrl = process.env.OPENAI_COMPAT_URL;
32
34
  return {
33
35
  ...(config.tools !== undefined ? { tools: config.tools } : {}),
36
+ ...(config.managedModels ? { managedModels: config.managedModels } : {}),
34
37
  url: (typeof config.url === "string" && config.url ? config.url : envUrl || "https://openrouter.ai/api/v1")
35
38
  .replace(/\/+$/, ""),
36
39
  apiKeyEnv: typeof config.apiKeyEnv === "string" && config.apiKeyEnv
@@ -74,15 +77,19 @@ export const OpenAICompatDriver = {
74
77
  process.env[config.apiKeyEnv] ??
75
78
  process.env.OPENAI_COMPAT_API_KEY ??
76
79
  "";
77
- let catalog = config.model
78
- ? {
79
- default: config.model,
80
- options: DEFAULT_MODELS.options.some((model) => model.id === config.model)
81
- ? DEFAULT_MODELS.options
82
- : [{ id: config.model, label: config.model, custom: true }, ...DEFAULT_MODELS.options],
83
- }
84
- : DEFAULT_MODELS;
80
+ let catalog = config.managedModels
81
+ ? { default: config.managedModels[0], options: config.managedModels.map(id => ({ id, label: id })) }
82
+ : config.model
83
+ ? {
84
+ default: config.model,
85
+ options: DEFAULT_MODELS.options.some((model) => model.id === config.model)
86
+ ? DEFAULT_MODELS.options
87
+ : [{ id: config.model, label: config.model, custom: true }, ...DEFAULT_MODELS.options],
88
+ }
89
+ : DEFAULT_MODELS;
85
90
  const fetchModels = async () => {
91
+ if (config.managedModels)
92
+ return;
86
93
  if (!apiKey)
87
94
  return;
88
95
  try {
@@ -126,6 +133,7 @@ export const OpenAICompatDriver = {
126
133
  apiKey,
127
134
  apiUrl: config.url,
128
135
  tools: config.tools,
136
+ computerUse: true,
129
137
  models: () => catalog,
130
138
  refreshModels: fetchModels,
131
139
  requestBody: (model, messages, stream) => ({
@@ -0,0 +1,35 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ /** Approval identity preserves every command byte, including whitespace and
4
+ * newlines. Callers must supply native shell input, never a display preview. */
5
+ export function permissionCommand(command, cwd) {
6
+ if (typeof command !== "string" || !command.trim() || command.includes("\0"))
7
+ return undefined;
8
+ if (typeof cwd !== "string" || !isAbsolute(cwd) || cwd.includes("\0"))
9
+ return undefined;
10
+ return { command, cwd };
11
+ }
12
+ export function permissionLaunchCwd(cwd) {
13
+ try {
14
+ return realpathSync(cwd);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ /** ACP's rawInput is provider-specific. Accept a complete command string and
21
+ * unambiguous absolute directory fields. A relative/invalid/conflicting
22
+ * directory is not evidence of where the agent will execute it. */
23
+ export function acpPermissionCommand(rawInput, launchCwd) {
24
+ if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput))
25
+ return undefined;
26
+ const input = rawInput;
27
+ const directories = ["cwd", "workdir", "working_directory", "directory", "dir"]
28
+ .filter((key) => input[key] !== undefined)
29
+ .map((key) => input[key]);
30
+ if (directories.some((value) => typeof value !== "string" || !isAbsolute(value) || value.includes("\0")))
31
+ return undefined;
32
+ if (new Set(directories).size > 1)
33
+ return undefined;
34
+ return permissionCommand(input.command, directories[0] ?? launchCwd);
35
+ }
@@ -6,6 +6,10 @@ import { createInterface } from "node:readline";
6
6
  import { delimiter, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  const MAX_ADB_OUTPUT = 16 * 1024 * 1024;
9
+ // Mirrors the shared-computer blockedReason shape: name the holder's
10
+ // situation, say the call did not happen, and tell the model what beats a
11
+ // blind retry once the phone frees up.
12
+ const PHONE_BUSY_TEXT = "Another thread is using the phone. This call was not performed. Pause phone work until that thread finishes, then read the screen again before acting.";
9
13
  const PACKAGE = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/;
10
14
  const COMPONENT = /^([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\/\S+$/;
11
15
  const SAFE_TEXT = /^[A-Za-z0-9 _.,@-]+$/;
@@ -240,6 +244,43 @@ const TOOLS = [
240
244
  { name: "press", description: "Press an Android navigation or keyboard key.", inputSchema: { type: "object", properties: { key: { type: "string", enum: Object.keys(KEYCODES) }, serial: { type: "string" } }, required: ["key"] } },
241
245
  ];
242
246
  const textResult = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
247
+ // The harness reserves the physical phone for one turn at a time, but only
248
+ // from the first real tool call (issue #1663): trigger-term matching decides
249
+ // whether these tools are mounted, never who holds the device. Revalidate
250
+ // every call: retained MCP processes can outlive their turn, whose claim and
251
+ // capability are released at settlement. Reclaiming for the same live owner
252
+ // is idempotent; a blocked caller can retry once the holding turn settles.
253
+ export function createPhoneClaim(env = process.env, fetchImpl = fetch) {
254
+ const url = env.OMB_HARNESS_URL?.trim();
255
+ const token = env.OMB_PHONE_TOKEN?.trim();
256
+ if (!url || !token) {
257
+ // Spawned outside the harness (development, direct debugging): no
258
+ // exclusivity to enforce, exactly the behavior this proxy always had.
259
+ return async () => ({ ok: true });
260
+ }
261
+ return async () => {
262
+ try {
263
+ const response = await fetchImpl(new URL("/api/internal/phone/claim", url), {
264
+ method: "POST",
265
+ redirect: "error",
266
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
267
+ body: "{}",
268
+ signal: AbortSignal.timeout(10_000),
269
+ });
270
+ if (response.ok)
271
+ return { ok: true };
272
+ if (response.status === 409)
273
+ return { ok: false, message: PHONE_BUSY_TEXT };
274
+ if (response.status === 401 || response.status === 403) {
275
+ return { ok: false, message: "This turn no longer has phone access. Start a new turn to use the phone." };
276
+ }
277
+ return { ok: false, message: "OpenMausBot could not reserve the phone for this call. This call was not performed; try again, or start a new turn if it keeps failing." };
278
+ }
279
+ catch {
280
+ return { ok: false, message: "OpenMausBot could not be reached to reserve the phone. This call was not performed; try again." };
281
+ }
282
+ };
283
+ }
243
284
  async function callTool(name, args) {
244
285
  if (name === "status") {
245
286
  const adb = resolveAdbPath();
@@ -334,7 +375,7 @@ async function callTool(name, args) {
334
375
  const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
335
376
  const ok = (id, result) => send({ jsonrpc: "2.0", id, result });
336
377
  const rpcError = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
337
- async function handle(message) {
378
+ async function handle(message, ensureClaim) {
338
379
  const id = message.id;
339
380
  const method = message.method;
340
381
  const params = (message.params ?? {});
@@ -350,6 +391,9 @@ async function handle(message) {
350
391
  const name = String(params.name ?? "");
351
392
  if (!TOOLS.some((tool) => tool.name === name))
352
393
  return rpcError(id, -32602, `Unknown tool: ${name}`);
394
+ const claim = await ensureClaim();
395
+ if (!claim.ok)
396
+ return ok(id, textResult(claim.message, true));
353
397
  try {
354
398
  return ok(id, await callTool(name, (params.arguments ?? {})));
355
399
  }
@@ -361,11 +405,12 @@ async function handle(message) {
361
405
  rpcError(id, -32601, `Method not found: ${String(method)}`);
362
406
  }
363
407
  if (process.argv[1] && existsSync(process.argv[1]) && /phone-proxy\.(?:ts|js)$/.test(process.argv[1])) {
408
+ const ensureClaim = createPhoneClaim();
364
409
  const lines = createInterface({ input: process.stdin, terminal: false });
365
410
  lines.on("line", (line) => {
366
411
  try {
367
412
  const message = JSON.parse(line);
368
- void handle(message).catch((error) => rpcError(message.id, -32603, error instanceof Error ? error.message : String(error)));
413
+ void handle(message, ensureClaim).catch((error) => rpcError(message.id, -32603, error instanceof Error ? error.message : String(error)));
369
414
  }
370
415
  catch { }
371
416
  });
@@ -30,6 +30,7 @@ import { SPAWNED_PROXIES } from "../proxy-paths.js";
30
30
  import { commandSummary, toolDetailPreview } from "../tool-summary.js";
31
31
  import { EFFORT_LEVELS } from "../../shared/wire.js";
32
32
  import { newEventId, newId } from "../contracts.js";
33
+ import { parseAskQuestions, parseChoices, questionAnswersByQuestion } from "../../shared/ask-question.js";
33
34
  import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
34
35
  import { appendNative } from "./native.js";
35
36
  const DRIVER_KIND = "piAgent";
@@ -425,6 +426,10 @@ export const PiDriver = {
425
426
  }
426
427
  const turnId = newId();
427
428
  const pending = new Map();
429
+ // Ask fail-safe timers, tracked so settle() can cancel them outright:
430
+ // a cleared pending map alone leaves each timer holding the ask
431
+ // closure (send, child) alive until it fires.
432
+ const askTimers = new Set();
428
433
  let settled = false;
429
434
  // pi's RPC surface accepts image content directly. Read before spawning
430
435
  // so an attachment that disappeared produces one clear dispatch error
@@ -522,6 +527,13 @@ export const PiDriver = {
522
527
  if (settled)
523
528
  return;
524
529
  settled = true;
530
+ // The turn is over: drop unanswered asks so their 15-minute
531
+ // fail-safe timers are cancelled outright instead of no-oping on a
532
+ // dead child while holding the ask closure alive.
533
+ for (const timer of askTimers)
534
+ clearTimeout(timer);
535
+ askTimers.clear();
536
+ pending.clear();
525
537
  flushAssistantText();
526
538
  emit({
527
539
  ...base(threadId, turnId),
@@ -626,26 +638,79 @@ export const PiDriver = {
626
638
  if (evt.method === "select" || evt.method === "confirm" || evt.method === "input") {
627
639
  flushAssistantText();
628
640
  const reqId = evt.id ?? newId();
629
- const isQuestion = evt.method === "input";
641
+ const isSelect = evt.method === "select";
642
+ const isQuestion = isSelect || evt.method === "input";
643
+ const selectOptions = isSelect && Array.isArray(evt.options)
644
+ ? evt.options.filter((option) => typeof option === "string") : [];
645
+ const summary = String(evt.title ?? (isQuestion ? "pi has a question" : "pi wants confirmation")).slice(0, 200);
646
+ // A select is a question with named options; the structured
647
+ // card renders from it while the flat choices keep older
648
+ // clients answering. An input has nothing to pick from and
649
+ // stays the free-text question it always was.
650
+ const question = isSelect
651
+ ? (parseAskQuestions({
652
+ questions: [{ question: summary, options: selectOptions }],
653
+ }) ?? [])[0]
654
+ : undefined;
655
+ const choices = question?.options.length ? question.options.map((option) => option.label) : undefined;
656
+ let timer;
630
657
  // Register BEFORE emitting: the harness may auto-approve from
631
658
  // inside its synchronous request.opened listener. Emitting first
632
659
  // made respondToRequest see no pending ask, return unavailable,
633
660
  // then fall back to a human card on every "Always allow" call.
634
661
  pending.set(reqId, (decision) => {
662
+ if (timer) {
663
+ clearTimeout(timer);
664
+ askTimers.delete(timer);
665
+ }
635
666
  if (decision.behavior === "deny")
636
667
  send({ type: "extension_ui_response", id: reqId, cancelled: true });
637
- else if (isQuestion)
638
- send({ type: "extension_ui_response", id: reqId, value: decision.message ?? "" });
668
+ else if (isQuestion) {
669
+ // Recover the picked label from a structured card's Q:/A:
670
+ // reply; a flat answer or typed text passes through
671
+ // verbatim (questionAnswersByQuestion's single-question
672
+ // fallback does exactly that).
673
+ const value = question
674
+ ? questionAnswersByQuestion(decision.message ?? "", [question])[question.question] ?? decision.message ?? ""
675
+ : decision.message ?? "";
676
+ // Display labels are capped/trimmed; pi expects the
677
+ // original option. Never guess if two normalize alike.
678
+ const matched = selectOptions.filter(option => parseChoices([option], 1)?.[0] === value);
679
+ send(matched.length > 1
680
+ ? { type: "extension_ui_response", id: reqId, cancelled: true }
681
+ : { type: "extension_ui_response", id: reqId, value: matched[0] ?? value });
682
+ }
639
683
  else
640
684
  send({ type: "extension_ui_response", id: reqId, confirmed: true });
641
685
  });
686
+ // The ask must never hold the turn forever: cancel it after 15
687
+ // minutes. The pending.delete guard makes this a no-op once the
688
+ // turn settled (settle clears pending) or a person answered.
689
+ timer = setTimeout(() => {
690
+ if (timer)
691
+ askTimers.delete(timer);
692
+ if (!pending.delete(reqId))
693
+ return;
694
+ send({ type: "extension_ui_response", id: reqId, cancelled: true });
695
+ emit({
696
+ ...base(threadId, turnId),
697
+ requestId: reqId,
698
+ type: "request.resolved",
699
+ behavior: "deny",
700
+ source: "timeout",
701
+ });
702
+ }, 15 * 60_000);
703
+ askTimers.add(timer);
704
+ timer.unref?.();
642
705
  emit({
643
706
  ...base(threadId, turnId),
644
707
  requestId: reqId,
645
708
  type: "request.opened",
646
709
  requestType: isQuestion ? "question" : "permission",
647
710
  tool: String(evt.title ?? "pi"),
648
- summary: String(evt.title ?? "pi wants confirmation"),
711
+ summary,
712
+ ...(choices ? { choices } : {}),
713
+ ...(question ? { questions: [question] } : {}),
649
714
  });
650
715
  }
651
716
  return;
@@ -751,6 +816,12 @@ export const PiDriver = {
751
816
  /* keep going on the engine default */
752
817
  }
753
818
  }
819
+ // pi compacts long sessions by summarizing older user messages, and
820
+ // this driver delivers the prompt as the leading user message: a
821
+ // receipt-based split would let a compacted session keep running
822
+ // bare, without its standing instructions. Re-deliver the full prompt
823
+ // every turn until pi exposes a compaction signal the harness can
824
+ // watch (its extension API has session_before_compact).
754
825
  const message = turn.system ? `${turn.system}\n\n${turn.text}` : turn.text;
755
826
  try {
756
827
  send({ type: "prompt", message, ...(images.length ? { images } : {}) });