openmausbot 0.1.86 → 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 (166) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-BTiuL79F.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-C88Blc2v.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-BZsJIZrb.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CVeMe9Ah.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-Cedk3Bhx.js} +1 -1
  7. package/dist/assets/channel-taCNRlUU.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-D-VRqzTB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-BChov01Y.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-C-TfwvHy.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-Db7CGj5Y.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-CNvIfqKT.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-C_bEp5tq.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Coi4BOls.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-CzylEuL3.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-m7p7ztaD.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BMy6o9x2.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-BDQ__0_J.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Djv4ghUb.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-BULrlGYH.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-D-jrT_hU.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-p2UhQmfL.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-DHvGWZ7q.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-ChhKDot-.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-B-T7A43R.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-CJm8ENNY.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-510tlza8.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-PkvRPHel.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-BVBVPoX3.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BgYMVm7L.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-BGqsnc_3.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-D_-UeReH.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-D0uooONx.js} +1 -1
  34. package/dist/assets/index-BwdMAQ6p.css +1 -0
  35. package/dist/assets/index-ByJennr7.js +581 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-zRsJERGJ.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-B9e1z77f.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-BB4vNwvD.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DpkosOTB.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-DbDP4kUu.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-DBJGPmjT.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-DWPRi-ue.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-nszBbJmx.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-CWykJWu_.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-g3Da-Vnv.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-Wzrlpzgh.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-moHTFMC0.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-CwfXdFH8.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-3ZROYvnq.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-E1TBebP8.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-DHKEU5pu.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BguEj09D.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-C7IDYnhd.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-DCW-F5PW.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-xBMFtWYV.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-B5nIkuH8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-DYFAhdbr.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-DeXwX6Cz.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/container-mcp.js +1029 -164
  63. package/dist-server/drivers/agents-proxy.js +64 -10
  64. package/dist-server/drivers/phone-proxy.js +33 -2
  65. package/dist-server/index.js +14577 -9001
  66. package/dist-server/local-computer-proxy.js +3 -3
  67. package/dist-server/local-computer.js +53 -17
  68. package/dist-server/openmausbot.js +3981 -1165
  69. package/dist-server/pair-cli.js +3981 -1165
  70. package/dist-server/permission-proxy.js +29 -9
  71. package/dist-server/server/attachments.js +55 -0
  72. package/dist-server/server/auto-approve.js +4 -3
  73. package/dist-server/server/auto-vm-claims.js +1 -0
  74. package/dist-server/server/bot-package.js +18 -292
  75. package/dist-server/server/bot-profile.js +5 -17
  76. package/dist-server/server/box-create-idempotency.js +3 -3
  77. package/dist-server/server/box.js +57 -55
  78. package/dist-server/server/browser-proxy.js +3 -2
  79. package/dist-server/server/browser-runtime.js +37 -1
  80. package/dist-server/server/cli.js +2 -2
  81. package/dist-server/server/command-allowlist.js +154 -0
  82. package/dist-server/server/computer-wait.js +22 -9
  83. package/dist-server/server/config.js +59 -9
  84. package/dist-server/server/connector-verdict.js +141 -0
  85. package/dist-server/server/container-computer.js +1 -1
  86. package/dist-server/server/default-model-selection.js +9 -0
  87. package/dist-server/server/delegations.js +85 -34
  88. package/dist-server/server/drivers/acp/core.js +57 -15
  89. package/dist-server/server/drivers/agents-call.js +42 -3
  90. package/dist-server/server/drivers/agents-catalog.js +34 -4
  91. package/dist-server/server/drivers/boxagent.js +174 -36
  92. package/dist-server/server/drivers/builtIn.js +2 -0
  93. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  94. package/dist-server/server/drivers/chat-images.js +54 -0
  95. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  96. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  97. package/dist-server/server/drivers/claude.js +7 -2
  98. package/dist-server/server/drivers/codex-instructions.js +48 -6
  99. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  100. package/dist-server/server/drivers/codex.js +95 -30
  101. package/dist-server/server/drivers/mistral.js +115 -0
  102. package/dist-server/server/drivers/openai-chat.js +155 -32
  103. package/dist-server/server/drivers/openai-compat.js +1 -0
  104. package/dist-server/server/drivers/permission-command.js +35 -0
  105. package/dist-server/server/drivers/phone-proxy.js +47 -2
  106. package/dist-server/server/drivers/pi.js +75 -4
  107. package/dist-server/server/drivers/prompt-split.js +87 -0
  108. package/dist-server/server/fleet.js +1 -1
  109. package/dist-server/server/group-thread-usage.js +103 -0
  110. package/dist-server/server/harness/registry.js +1 -0
  111. package/dist-server/server/hosted-models.js +1 -1
  112. package/dist-server/server/index.js +1385 -322
  113. package/dist-server/server/local-computer.js +72 -23
  114. package/dist-server/server/managed-desktop.js +1 -1
  115. package/dist-server/server/mcp-bridge.js +6 -6
  116. package/dist-server/server/new-bot-defaults.js +115 -0
  117. package/dist-server/server/org-library.js +1051 -0
  118. package/dist-server/server/package-export.js +627 -39
  119. package/dist-server/server/package-import.js +458 -0
  120. package/dist-server/server/package-parts.js +87 -0
  121. package/dist-server/server/peer-approval.js +16 -7
  122. package/dist-server/server/peer-roster.js +10 -0
  123. package/dist-server/server/presets.js +492 -0
  124. package/dist-server/server/provider-key-check.js +3 -2
  125. package/dist-server/server/published-teams.js +80 -0
  126. package/dist-server/server/room-handoffs.js +19 -3
  127. package/dist-server/server/routes/bot-presets.js +25 -0
  128. package/dist-server/server/routine-requests.js +6 -3
  129. package/dist-server/server/routines.js +41 -1
  130. package/dist-server/server/section-context.js +10 -0
  131. package/dist-server/server/skills.js +34 -61
  132. package/dist-server/server/steer-queue.js +3 -2
  133. package/dist-server/server/store.js +259 -15
  134. package/dist-server/server/surface.js +10 -4
  135. package/dist-server/server/system-prompt.js +12 -4
  136. package/dist-server/server/team-backup.js +6 -2
  137. package/dist-server/server/team-computers.js +10 -0
  138. package/dist-server/server/team-library.js +6 -2
  139. package/dist-server/server/team-manifest.js +4 -3
  140. package/dist-server/server/team-setup-requests.js +16 -5
  141. package/dist-server/server/thread-events.js +17 -1
  142. package/dist-server/server/turn-context.js +2 -0
  143. package/dist-server/server/turn-watchdog.js +12 -3
  144. package/dist-server/server/usage-ledger.js +12 -1
  145. package/dist-server/server/vps-computer.js +139 -51
  146. package/dist-server/server/vps-container-mcp.js +7 -0
  147. package/dist-server/server/vps-ssh.js +36 -7
  148. package/dist-server/server/workspace-backup-policy.js +12 -0
  149. package/dist-server/server/workspace-backup.js +2 -2
  150. package/dist-server/server/workspace.js +1 -1
  151. package/dist-server/shared/approval-mode.js +1 -1
  152. package/dist-server/shared/ask-question.js +188 -12
  153. package/dist-server/shared/bot-profile.js +15 -0
  154. package/dist-server/shared/command-allowlist.js +1 -0
  155. package/dist-server/shared/credential-request.js +1 -1
  156. package/dist-server/shared/package-format.js +967 -0
  157. package/dist-server/shared/skill-md.js +64 -0
  158. package/dist-server/shared/team-backup.js +7 -0
  159. package/dist-server/shared/wire.js +4 -0
  160. package/dist-server/vps-container-mcp.js +1129 -170
  161. package/enterprise/README.md +1 -1
  162. package/package.json +1 -1
  163. package/dist/assets/channel-BXFkgus9.js +0 -1
  164. package/dist/assets/index-BGgqlWPJ.css +0 -1
  165. package/dist/assets/index-BtHo8ZKl.js +0 -577
  166. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -1,5 +1,6 @@
1
1
  import { newEventId, newId } from "../contracts.js";
2
2
  import { appendNative } from "./native.js";
3
+ import { OMB_ASK_TOOL, answerWithoutPreamble, askQuestionSummary, capAnswerEcho, parseOmbAskQuestions, questionChoices, stripOmbAskBlock, } from "../../shared/ask-question.js";
3
4
  const DRIVER_KIND = "boxAgent";
4
5
  // overridable so tests and a dev backend can be pointed at instead of the live provider
5
6
  const BOX_API = process.env.OMB_BOX_API || "https://ascii.dev/api/box/v1";
@@ -11,7 +12,24 @@ const MODELS = {
11
12
  { id: "gpt-5.4", label: "GPT-5.4 (Codex) · on the box" },
12
13
  ],
13
14
  };
14
- /** The box runs every harness ascii.dev ships (claude-code, codex, pi, opencode,
15
+ /** The ask contract appended to every prompt. The box harness cannot pause
16
+ * mid-run, so a question rides the run's final output as a fenced block and
17
+ * OMB parses it at settle — the turn-held transport. */
18
+ const ASK_PROTOCOL = [
19
+ "",
20
+ "## Asking the person a question",
21
+ "When a decision belongs to the person, end your reply with a fenced block exactly like this:",
22
+ "",
23
+ "```omb-ask",
24
+ '{"questions":[{"question":"Ship the release now?","header":"Release","options":[{"label":"Ship now"},{"label":"Wait for the QA signoff"}]}]}',
25
+ "```",
26
+ "",
27
+ "The block must be the last thing in your reply. You may ask up to 6 questions at once, each with up to 12 options; the person can always answer in their own words. Their answers arrive on your next prompt as `Q:`/`A:` lines — never invent them.",
28
+ ].join("\n");
29
+ /** Any fence whose info string names the ask protocol, even when its body
30
+ * does not parse: the marker for "the model tried to ask and failed". */
31
+ const ASK_FENCE_ANY = /(^|\n)[ \t]{0,3}(`{3,}|~{3,})[ \t]*omb-ask\b/;
32
+ /** The box runs every harness boat.dev ships (claude-code, codex, pi, opencode,
15
33
  * prime-agent, kimi). Which one a model id belongs to comes from the public
16
34
  * catalog, `GET /api/provider-models` at the API root: an object keyed by
17
35
  * harness, each with its `models`. A bot that arrives here from another engine
@@ -35,7 +53,10 @@ const providerFor = (model) => {
35
53
  };
36
54
  function decodeConfig(raw) {
37
55
  const o = (raw ?? {});
38
- return { pollMs: typeof o.pollMs === "number" ? o.pollMs : 2500 };
56
+ return {
57
+ pollMs: typeof o.pollMs === "number" ? o.pollMs : 2500,
58
+ ...(typeof o.askTimeoutMs === "number" ? { askTimeoutMs: o.askTimeoutMs } : {}),
59
+ };
39
60
  }
40
61
  export const BoxAgentDriver = {
41
62
  driverKind: DRIVER_KIND,
@@ -48,6 +69,13 @@ export const BoxAgentDriver = {
48
69
  const token = input.environment.BOX_TOKEN ?? process.env.BOX_TOKEN ?? "";
49
70
  const listeners = new Set();
50
71
  const active = new Map();
72
+ /** One open ask per thread: the turn-held transport's pending card. The
73
+ * thread is busy for exactly as long as the ask is open, so a second
74
+ * block can never race the first. */
75
+ const heldAsks = new Map();
76
+ /** Threads whose last run ended with an unparseable omb-ask fence: the
77
+ * next prompt carries the correction so the ask is never lost silently. */
78
+ const malformedAsks = new Set();
51
79
  const emit = (event) => {
52
80
  for (const l of Array.from(listeners))
53
81
  l(event);
@@ -84,46 +112,65 @@ export const BoxAgentDriver = {
84
112
  throw new Error("a turn is already running on this thread");
85
113
  const turnId = newId();
86
114
  const model = turn.model || MODELS.default;
115
+ const correction = malformedAsks.delete(threadId)
116
+ ? "Your previous omb-ask block was malformed or empty, so the person never saw it. Ask again with a valid fenced omb-ask JSON block, or ask in plain words."
117
+ : "";
87
118
  const prompt = [
88
119
  turn.system,
89
120
  "You are working on the assigned cloud computer — use its desktop, Chrome, and shell within the access described above.",
121
+ ASK_PROTOCOL,
122
+ ...(correction ? [correction] : []),
90
123
  "",
91
124
  turn.text,
92
125
  ]
93
126
  .filter((s) => s !== undefined)
94
127
  .join("\n");
95
- if (!catalog)
96
- await loadCatalog();
97
- const started = await api(`/boxes/${boxId}/prompt`, {
98
- method: "POST",
99
- body: JSON.stringify({ ...providerFor(model), prompt }),
100
- });
101
- appendNative(threadId, { dir: "out", source: "box.prompt", msg: { model, prompt, response: started } });
102
- // real shape (2026-08): {type:"prompt.queued", promptId, promptRun:{id,…},
103
- // id:<box id>} — never fall back to the bare id, it's the box's
104
- const promptId = started?.promptRun?.id ?? started?.prompt?.id ?? started?.promptId ?? null;
128
+ const postPrompt = async (promptText) => {
129
+ if (!catalog)
130
+ await loadCatalog();
131
+ const started = await api(`/boxes/${boxId}/prompt`, {
132
+ method: "POST",
133
+ body: JSON.stringify({ ...providerFor(model), prompt: promptText }),
134
+ });
135
+ appendNative(threadId, { dir: "out", source: "box.prompt", msg: { model, prompt: promptText, response: started } });
136
+ // real shape (2026-08): {type:"prompt.queued", promptId, promptRun:{id,…},
137
+ // id:<box id>} — never fall back to the bare id, it's the box's
138
+ return started?.promptRun?.id ?? started?.prompt?.id ?? started?.promptId ?? null;
139
+ };
140
+ const promptId = await postPrompt(prompt);
105
141
  let cancelled = false;
106
142
  active.set(threadId, {
107
143
  turnId,
108
144
  boxId,
109
145
  cancel: () => {
110
146
  cancelled = true;
147
+ heldAsks.get(threadId)?.settle(null, "system");
111
148
  void api(`/boxes/${boxId}/interrupt`, { method: "POST" }).catch(() => { });
112
149
  },
113
150
  });
114
151
  emit({ ...base(threadId, turnId), type: "turn.started" });
115
152
  emit({ ...base(threadId, turnId), type: "session.started", sessionId: promptId, model });
116
- // poll events + run status until the prompt settles
153
+ // poll events + run status until the prompt settles — and, when the
154
+ // run ends on an omb-ask block, until the person answers it: the OMB
155
+ // turn is the unit the whole server already understands (busy thread,
156
+ // waiting-on-you, routines waiting), so it stays open across the ask
157
+ // and the continuation prompt continues it rather than starting a
158
+ // driver-initiated turn nobody accounts for.
117
159
  (async () => {
160
+ // Event ids seen this TURN. The events stream is the whole
161
+ // conversation, so a continuation run must not re-ingest history;
162
+ // `taskId` normally filters it, but ids survive even shape drift.
118
163
  const seen = new Set();
119
- const startedAt = Date.now();
120
164
  let lastText = "";
121
165
  let pendingText = "";
122
166
  /** Why the box could not answer (login expired, model refused, …). */
123
167
  let problem = null;
124
- /** Emit unflushed deltas as assistant_text and reset pendingText. */
168
+ /** Emit unflushed deltas as assistant_text and reset pendingText.
169
+ * The omb-ask block is protocol, not prose: it streamed raw (the
170
+ * card is its readable form), and the settled message shows the
171
+ * words around it, never the JSON. */
125
172
  const flushAssistantText = () => {
126
- const text = pendingText;
173
+ const text = stripOmbAskBlock(pendingText);
127
174
  pendingText = "";
128
175
  if (!text.trim())
129
176
  return;
@@ -138,7 +185,88 @@ export const BoxAgentDriver = {
138
185
  pendingText += delta;
139
186
  emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta });
140
187
  };
141
- try {
188
+ /** The run has settled: flush its prose, then hold the OMB turn open
189
+ * on an omb-ask block (if any) until the person answers or the ask
190
+ * times out. An answer chains the continuation run under this same
191
+ * turn; a deny or timeout ends it. */
192
+ const finishRun = async (ok, stopReason) => {
193
+ flushAssistantText();
194
+ // An interrupt that lands between ask text streaming and settle
195
+ // finds no heldAsks entry to settle; without this check the run
196
+ // would register a fresh ask for a dead turn and hold stop pending
197
+ // for the ask timeout.
198
+ if (cancelled) {
199
+ return { ok: false, stopReason: "interrupted" };
200
+ }
201
+ // A remotely interrupted or failed run must stay stopped, even
202
+ // if its partial output already contains a complete question.
203
+ if (!ok)
204
+ return { ok, stopReason };
205
+ const questions = parseOmbAskQuestions(lastText);
206
+ if (!questions) {
207
+ // A fence that did not parse is a question the person never saw.
208
+ // Say so on the next prompt instead of losing it silently. (An
209
+ // over-cap block does not land here: the parser caps it at six
210
+ // questions and still shows the card.)
211
+ if (ASK_FENCE_ANY.test(lastText))
212
+ malformedAsks.add(threadId);
213
+ return { ok: ok && !cancelled, stopReason: cancelled ? "interrupted" : stopReason };
214
+ }
215
+ const requestId = newId();
216
+ let settleHeld;
217
+ const held = new Promise((resolve) => {
218
+ const timer = setTimeout(() => settleHeld(null, "timeout"), config.askTimeoutMs ?? 15 * 60_000);
219
+ timer.unref?.();
220
+ settleHeld = (reply, source) => {
221
+ heldAsks.delete(threadId);
222
+ clearTimeout(timer);
223
+ resolve([reply, source]);
224
+ };
225
+ });
226
+ // register before emitting — an answer can race the emit
227
+ heldAsks.set(threadId, { requestId, settle: settleHeld });
228
+ const choices = questionChoices(questions);
229
+ emit({
230
+ ...base(threadId, turnId),
231
+ requestId,
232
+ type: "request.opened",
233
+ requestType: "question",
234
+ tool: OMB_ASK_TOOL,
235
+ summary: askQuestionSummary(questions),
236
+ questions,
237
+ ...(choices?.length ? { choices } : {}),
238
+ origin: "output",
239
+ });
240
+ const [reply, source] = await held;
241
+ emit({ ...base(threadId, turnId), requestId, type: "request.resolved", behavior: reply !== null ? "answer" : "deny", source });
242
+ if (reply === null || cancelled) {
243
+ return { ok: ok && !cancelled, stopReason: cancelled ? "interrupted" : stopReason };
244
+ }
245
+ // The turn is still open, so this prompt continues it: same turnId,
246
+ // same accounting, and the answer reaches the box the way the ask
247
+ // contract promised — Q:/A: blocks, capped for echo.
248
+ const continuation = [
249
+ capAnswerEcho(answerWithoutPreamble(reply)),
250
+ "",
251
+ "The person answered the omb-ask questions above (Q:/A:). Continue the task with their answers; end with another omb-ask block only if you truly need more.",
252
+ ].join("\n");
253
+ const nextPromptId = await postPrompt(continuation);
254
+ // Stop can land while the continuation POST is in flight: the
255
+ // interrupt inside cancel() then hits a box with no active run,
256
+ // and the continuation would start after it. Interrupt the run
257
+ // that just started before ending the turn.
258
+ if (cancelled) {
259
+ void api(`/boxes/${boxId}/interrupt`, { method: "POST" }).catch(() => { });
260
+ return { ok: false, stopReason: "interrupted" };
261
+ }
262
+ return await settleRun(nextPromptId);
263
+ };
264
+ /** Poll one box run to its settle. */
265
+ const settleRun = async (runPromptId) => {
266
+ const startedAt = Date.now(); // one 30-min ceiling per box run, ask chains included
267
+ lastText = "";
268
+ pendingText = "";
269
+ problem = null;
142
270
  for (;;) {
143
271
  if (cancelled)
144
272
  break;
@@ -149,7 +277,7 @@ export const BoxAgentDriver = {
149
277
  // The stream is the whole conversation from its start; `taskId`
150
278
  // names the prompt run an event belongs to. Earlier turns are
151
279
  // history, not this answer.
152
- if (promptId && ev.taskId && String(ev.taskId) !== promptId)
280
+ if (runPromptId && ev.taskId && String(ev.taskId) !== runPromptId)
153
281
  continue;
154
282
  const id = String(ev.id ?? ev.eventId ?? JSON.stringify(ev).slice(0, 120));
155
283
  if (seen.has(id))
@@ -184,16 +312,13 @@ export const BoxAgentDriver = {
184
312
  // shape-drift backstop: without a promptId the status poll
185
313
  // below can never see a terminal state, so settle off the
186
314
  // events themselves instead of hanging to the 30-min ceiling
187
- if (!promptId && /complete|finish|done|success|fail|error/i.test(kind)) {
188
- active.delete(threadId);
189
- flushAssistantText();
315
+ if (!runPromptId && /complete|finish|done|success|fail|error/i.test(kind)) {
190
316
  const failed = /fail|error/i.test(kind);
191
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: !failed, stopReason: failed ? kind : null, cost: null });
192
- return;
317
+ return await finishRun(!failed, failed ? kind : null);
193
318
  }
194
319
  }
195
- if (promptId) {
196
- const status = await api(`/boxes/${boxId}/prompts/${promptId}`).catch(() => null);
320
+ if (runPromptId) {
321
+ const status = await api(`/boxes/${boxId}/prompts/${runPromptId}`).catch(() => null);
197
322
  appendNative(threadId, { dir: "in", source: "box.prompt.status", msg: status });
198
323
  // real shape (2026-08): {promptRun:{status:"finished",…}} —
199
324
  // flat fallbacks kept for drift
@@ -211,19 +336,13 @@ export const BoxAgentDriver = {
211
336
  throw new Error(problem);
212
337
  pendingText = "(finished)";
213
338
  }
214
- flushAssistantText();
215
- active.delete(threadId);
216
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: true, stopReason: null, cost: null });
217
- return;
339
+ return await finishRun(true, null);
218
340
  }
219
341
  if (/failed|error|cancelled|interrupted/i.test(state)) {
220
342
  const runError = [run?.error, run?.failureReason, run?.message].find((v) => typeof v === "string" && v.trim());
221
343
  if (problem || runError || /failed|error/i.test(state))
222
344
  throw new Error(problem ?? runError ?? `the box run ${state}`);
223
- flushAssistantText();
224
- active.delete(threadId);
225
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: state, cost: null });
226
- return;
345
+ return await finishRun(false, state);
227
346
  }
228
347
  }
229
348
  if (Date.now() - startedAt > 30 * 60_000) {
@@ -231,12 +350,16 @@ export const BoxAgentDriver = {
231
350
  }
232
351
  }
233
352
  // cancelled
234
- flushAssistantText();
353
+ return await finishRun(false, "interrupted");
354
+ };
355
+ try {
356
+ const outcome = await settleRun(promptId);
235
357
  active.delete(threadId);
236
- emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null });
358
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: outcome.ok, stopReason: outcome.stopReason, cost: null });
237
359
  }
238
360
  catch (e) {
239
361
  flushAssistantText();
362
+ heldAsks.delete(threadId);
240
363
  active.delete(threadId);
241
364
  emit({ ...base(threadId, turnId), type: "runtime.error", message: e.message });
242
365
  emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "error", cost: null });
@@ -268,7 +391,22 @@ export const BoxAgentDriver = {
268
391
  capabilities: { sessionModelSwitch: "in-session" },
269
392
  sendTurn,
270
393
  interruptTurn: async (threadId) => active.get(threadId)?.cancel(),
271
- respondToRequest: async () => "unavailable", // this engine has no asks to answer
394
+ respondToRequest: async (threadId, requestId, decision) => {
395
+ const held = heldAsks.get(threadId);
396
+ if (!held || held.requestId !== requestId)
397
+ return "unavailable"; // settled, timed out, or restarted away
398
+ const reply = decision.message?.trim();
399
+ if (decision.behavior === "answer" && reply) {
400
+ held.settle(decision.message, "user");
401
+ return "answered";
402
+ }
403
+ if (decision.behavior === "deny") {
404
+ held.settle(null, "user");
405
+ return "rejected";
406
+ }
407
+ // "allow" is not an answer to a question, and blank text is not either
408
+ return "unavailable";
409
+ },
272
410
  hasSession: (threadId) => active.has(threadId),
273
411
  stopAll: async () => {
274
412
  for (const { cancel } of active.values())
@@ -14,6 +14,7 @@ import { CustomAcpDriver } from "./acp/custom.js";
14
14
  import { HermesAgentDriver } from "./acp/hermes.js";
15
15
  import { OpenAICompatDriver } from "./openai-compat.js";
16
16
  import { PiDriver } from "./pi.js";
17
+ import { MistralDriver } from "./mistral.js";
17
18
  import { MinimaxDriver } from "./minimax.js";
18
19
  export const BUILT_IN_DRIVERS = [
19
20
  GrokDriver,
@@ -33,4 +34,5 @@ export const BUILT_IN_DRIVERS = [
33
34
  AntigravityDriver,
34
35
  BoxAgentDriver,
35
36
  MinimaxDriver,
37
+ MistralDriver,
36
38
  ];
@@ -0,0 +1,90 @@
1
+ import { isolatedRemoteCommand, runCommand, screenshotBox } from "../box.js";
2
+ import { createControlClient, CONTROL_REFUSAL_PLAIN } from "../control-client.js";
3
+ const coordinate = { type: "integer", minimum: 0, maximum: 32767 };
4
+ const tool = (name, description, properties = {}, required = []) => ({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } });
5
+ const TOOLS = [
6
+ tool("screenshot", "See the assigned cloud desktop at native pixel resolution."),
7
+ tool("get_screen_size", "Get the cloud desktop width and height."),
8
+ tool("click", "Click a point on the cloud desktop.", { x: coordinate, y: coordinate, button: { type: "string", enum: ["left", "middle", "right"] }, count: { type: "integer", minimum: 1, maximum: 3 } }, ["x", "y"]),
9
+ tool("move", "Move the cloud mouse pointer.", { x: coordinate, y: coordinate }, ["x", "y"]),
10
+ tool("drag", "Drag on the cloud desktop.", { x: coordinate, y: coordinate, to_x: coordinate, to_y: coordinate }, ["x", "y", "to_x", "to_y"]),
11
+ tool("type_text", "Type Unicode text into the focused cloud application.", { text: { type: "string", maxLength: 4000 } }, ["text"]),
12
+ tool("key_press", "Press an X11 key or shortcut.", { key: { type: "string", pattern: "^[A-Za-z0-9_+]+$", minLength: 1, maxLength: 100 } }, ["key"]),
13
+ tool("scroll", "Scroll at a point on the cloud desktop.", { x: coordinate, y: coordinate, direction: { type: "string", enum: ["up", "down", "left", "right"] }, amount: { type: "integer", minimum: 1, maximum: 30 } }, ["x", "y", "direction"]),
14
+ tool("open_url", "Request opening an HTTP or HTTPS URL in the cloud desktop browser; inspect the screen to confirm it loaded.", { url: { type: "string", pattern: "^https?://", maxLength: 2000 } }, ["url"]),
15
+ tool("exec", "Run a shell command on the assigned cloud computer.", { command: { type: "string", minLength: 1, maxLength: 4000 } }, ["command"]),
16
+ ];
17
+ const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
18
+ const buttons = { left: 1, middle: 2, right: 3 };
19
+ const scrollButtons = { up: 4, down: 5, left: 6, right: 7 };
20
+ /** Uses only the leased descriptor supplied by the harness. Schema validation
21
+ * and user approval happen in the shared chat tool executor before this call. */
22
+ export class ChatBoxClient {
23
+ closed = false;
24
+ control;
25
+ descriptor;
26
+ constructor(descriptor) {
27
+ this.descriptor = descriptor;
28
+ if (!descriptor.control?.url || !descriptor.control.token)
29
+ throw new Error("Cloud computer control gate missing");
30
+ this.control = createControlClient(descriptor.control);
31
+ }
32
+ async tools() { return TOOLS; }
33
+ async close() { this.closed = true; }
34
+ async call(_method, params, signal) {
35
+ signal.throwIfAborted();
36
+ if (this.closed)
37
+ throw new Error("Cloud computer session closed");
38
+ const state = await this.control.state(true);
39
+ signal.throwIfAborted();
40
+ if (this.closed)
41
+ throw new Error("Cloud computer session closed");
42
+ if (state.held || state.blockedReason)
43
+ return { isError: true, content: [{ type: "text", text: state.blockedReason || CONTROL_REFUSAL_PLAIN }] };
44
+ const { name, arguments: a } = params;
45
+ const config = { box: { token: this.descriptor.token } };
46
+ const id = this.descriptor.boxId;
47
+ if (name === "screenshot") {
48
+ const shot = await screenshotBox(config, "", id, { signal, nativeSize: true });
49
+ signal.throwIfAborted();
50
+ return { content: [{ type: "image", mimeType: "image/jpeg", data: shot.png }] };
51
+ }
52
+ const mouse = `xdotool mousemove --sync ${a.x} ${a.y}`;
53
+ let command;
54
+ switch (name) {
55
+ case "get_screen_size":
56
+ command = "xdotool getdisplaygeometry";
57
+ break;
58
+ case "click":
59
+ command = `${mouse} click --repeat ${a.count ?? 1} --delay 100 ${buttons[String(a.button ?? "left")]}`;
60
+ break;
61
+ case "move":
62
+ command = mouse;
63
+ break;
64
+ case "drag":
65
+ command = `${mouse} mousedown 1 mousemove --sync ${a.to_x} ${a.to_y} mouseup 1`;
66
+ break;
67
+ case "type_text":
68
+ command = `printf %s ${quote(Buffer.from(String(a.text)).toString("base64"))} | base64 -d | xclip -selection clipboard && xdotool key --clearmodifiers ctrl+v`;
69
+ break;
70
+ case "key_press":
71
+ command = `xdotool key --clearmodifiers ${quote(String(a.key))}`;
72
+ break;
73
+ case "scroll":
74
+ command = `${mouse} click --repeat ${a.amount ?? 3} --delay 80 ${scrollButtons[String(a.direction)]}`;
75
+ break;
76
+ case "open_url":
77
+ command = `nohup xdg-open ${quote(String(a.url))} >/dev/null 2>&1 </dev/null &`;
78
+ break;
79
+ case "exec":
80
+ command = String(a.command);
81
+ break;
82
+ default: throw new Error("Unknown cloud computer tool");
83
+ }
84
+ const result = await runCommand(config, id, isolatedRemoteCommand(command), { signal });
85
+ signal.throwIfAborted();
86
+ if (name === "open_url" && result.ok)
87
+ return { content: [{ type: "text", text: "Browser launch requested. Page loading is not confirmed; inspect the screen before continuing." }] };
88
+ return { isError: !result.ok, content: [{ type: "text", text: JSON.stringify(result) }] };
89
+ }
90
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ const IMAGE_BYTES = 20 * 1024 * 1024;
3
+ const MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
4
+ export const CHAT_IMAGE_BUDGET = 32 * 1024 * 1024;
5
+ /** Bound retained base64 before JSON serialization or another model call. */
6
+ export function chatImageBudget() {
7
+ let used = 0;
8
+ return (parts) => {
9
+ if (!Array.isArray(parts))
10
+ return;
11
+ const added = parts.reduce((total, part) => total + (part.type === "image_url" ? part.image_url.url.length : 0), 0);
12
+ if (used + added > CHAT_IMAGE_BUDGET)
13
+ throw new Error("Turn image budget exceeded (32 MiB encoded); screenshot cannot be retained. An operation may already have taken effect; inspect its state before retrying.");
14
+ used += added;
15
+ };
16
+ }
17
+ export function assertImageTransport(url) {
18
+ const endpoint = new URL(url);
19
+ const loopback = endpoint.hostname === "localhost" || endpoint.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(endpoint.hostname);
20
+ if (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) {
21
+ throw new Error("Images and computer tools require HTTPS for remote API endpoints; HTTP is supported only on loopback.");
22
+ }
23
+ }
24
+ export function chatImage(item) {
25
+ const { mimeType, data } = item;
26
+ if (typeof mimeType !== "string" || !MIME.has(mimeType) || typeof data !== "string" ||
27
+ !data.length || data.length > Math.ceil(IMAGE_BYTES / 3) * 4 || data.length % 4 ||
28
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
29
+ throw new Error("Invalid or oversized MCP image");
30
+ }
31
+ const bytes = Buffer.from(data, "base64");
32
+ const matches = mimeType === "image/png" ? bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) && bytes.toString("ascii", 12, 16) === "IHDR"
33
+ : mimeType === "image/jpeg" ? bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
34
+ : mimeType === "image/gif" ? bytes.length >= 13 && ["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))
35
+ : bytes.length >= 16 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
36
+ if (bytes.length > IMAGE_BYTES || bytes.toString("base64") !== data || !matches)
37
+ throw new Error("Image bytes do not match the declared MIME type");
38
+ return { type: "image_url", image_url: { url: `data:${mimeType};base64,${data}` } };
39
+ }
40
+ export function chatUserContent(turn) {
41
+ if (!turn.images?.length)
42
+ return turn.text;
43
+ const budget = chatImageBudget();
44
+ return [{ type: "text", text: turn.text }, ...turn.images.map(image => {
45
+ if (statSync(image.path).size > IMAGE_BYTES)
46
+ throw new Error("Image exceeds 20 MB");
47
+ const part = chatImage({ mimeType: image.mime, data: readFileSync(image.path).toString("base64") });
48
+ budget([part]);
49
+ return part;
50
+ })];
51
+ }
52
+ export function chatToolImages(callId, images) {
53
+ return images?.length ? [{ type: "text", text: `Screenshot result from tool call ${callId}:` }, ...images] : [];
54
+ }
@@ -6,6 +6,8 @@ import formats from "ajv-formats";
6
6
  import { stripControlPlaneEnv } from "../config.js";
7
7
  import { augmentedPath } from "../env-path.js";
8
8
  import { killCliTree, spawnCli } from "../procs.js";
9
+ import { chatImage } from "./chat-images.js";
10
+ import { ChatBoxClient } from "./chat-box-tools.js";
9
11
  /** The transport cannot safely continue this turn. A dispatched operation may
10
12
  * already have taken effect, so callers must not retry it through a new round. */
11
13
  export class ChatToolSessionError extends Error {
@@ -32,6 +34,7 @@ export function chatMcpEnvironment(serverEnv, source = process.env) {
32
34
  return { ...env, ...serverEnv };
33
35
  }
34
36
  class ChatMcpClient {
37
+ frameBytes;
35
38
  child;
36
39
  buffer = "";
37
40
  nextId = 1;
@@ -39,7 +42,8 @@ class ChatMcpClient {
39
42
  closed = false;
40
43
  closing;
41
44
  pending = new Map();
42
- constructor(server) {
45
+ constructor(server, computerUse = false) {
46
+ this.frameBytes = computerUse ? 32 * 1024 * 1024 : FRAME_BYTES;
43
47
  try {
44
48
  // The desktop shell inherits Finder's bare PATH, where `npx`-style
45
49
  // servers cannot find `node` and exit at once. Widen it the way the
@@ -89,7 +93,7 @@ class ChatMcpClient {
89
93
  if (this.closed || !this.child.stdin.writable || this.child.stdin.destroyed)
90
94
  throw new Error("MCP session closed");
91
95
  const encoded = JSON.stringify(frame) + "\n";
92
- if (Buffer.byteLength(encoded) > FRAME_BYTES)
96
+ if (Buffer.byteLength(encoded) > this.frameBytes)
93
97
  throw new Error("MCP request exceeds the frame limit");
94
98
  this.child.stdin.write(encoded);
95
99
  }
@@ -103,7 +107,7 @@ class ChatMcpClient {
103
107
  this.buffer = this.buffer.slice(newline + 1);
104
108
  if (++this.frames > MAX_FRAMES)
105
109
  return this.fail(new Error("MCP session exceeded the response frame count limit"));
106
- if (Buffer.byteLength(line) > FRAME_BYTES)
110
+ if (Buffer.byteLength(line) > this.frameBytes)
107
111
  return this.fail(new Error("MCP response exceeds the frame limit"));
108
112
  if (!line.trim())
109
113
  continue;
@@ -140,7 +144,7 @@ class ChatMcpClient {
140
144
  else
141
145
  entry.resolve(message.result);
142
146
  }
143
- if (Buffer.byteLength(this.buffer) > FRAME_BYTES)
147
+ if (Buffer.byteLength(this.buffer) > this.frameBytes)
144
148
  this.fail(new Error("MCP response exceeds the frame limit"));
145
149
  }
146
150
  async call(method, params, signal, timeout) {
@@ -244,6 +248,8 @@ function compileSchema(schema) {
244
248
  // ajv-formats is CommonJS and exports the plugin as both module.exports
245
249
  // and .default; the latter also matches its NodeNext declaration.
246
250
  formats.default(compiler);
251
+ compiler.addFormat("uint32", { type: "number", validate: value => Number.isInteger(value) && value >= 0 && value <= 4294967295 });
252
+ compiler.addFormat("uint64", { type: "number", validate: value => Number.isSafeInteger(value) && value >= 0 });
247
253
  try {
248
254
  return compiler.compile(schema);
249
255
  }
@@ -260,8 +266,14 @@ function boundedText(value) {
260
266
  end -= 1;
261
267
  return `${bytes.subarray(0, end).toString()}\n[MCP result truncated at 50KB; request less output.]`;
262
268
  }
263
- export async function mountChatTools(integrations, signal) {
269
+ export async function mountChatTools(integrations, signal, computerUse = false) {
264
270
  const servers = [];
271
+ if (computerUse && integrations?.computer)
272
+ servers.push(["computer", integrations.computer]);
273
+ if (computerUse && integrations?.localComputer)
274
+ servers.push(["computer", integrations.localComputer]);
275
+ if (computerUse && integrations?.browser)
276
+ servers.push(["browser", integrations.browser]);
265
277
  if (integrations?.agents)
266
278
  servers.push(["agents", integrations.agents]);
267
279
  if (integrations?.composio)
@@ -300,7 +312,9 @@ export async function mountChatTools(integrations, signal) {
300
312
  const mounts = await Promise.allSettled(servers.map(async ([name, descriptor]) => {
301
313
  if (signal.aborted || closed)
302
314
  throw aborted();
303
- const client = new ChatMcpClient(descriptor);
315
+ // Every mounted MCP server can return images when the caller enables
316
+ // image delivery, including custom servers. Text stays bounded below.
317
+ const client = "boxId" in descriptor ? new ChatBoxClient(descriptor) : new ChatMcpClient(descriptor, computerUse);
304
318
  clients.push(client);
305
319
  return { name, client, tools: await client.tools(signal) };
306
320
  }));
@@ -320,6 +334,18 @@ export async function mountChatTools(integrations, signal) {
320
334
  if (Buffer.byteLength(JSON.stringify(tool.inputSchema)) > SCHEMA_BYTES)
321
335
  throw new Error("MCP tool schema exceeds the 64KB limit");
322
336
  const schema = compileSchema(tool.inputSchema);
337
+ const parameters = { ...tool.inputSchema };
338
+ const constraints = {};
339
+ if (computerUse) {
340
+ for (const key of ["anyOf", "oneOf", "allOf", "not"]) {
341
+ if (key in parameters) {
342
+ constraints[key] = parameters[key];
343
+ delete parameters[key];
344
+ }
345
+ }
346
+ }
347
+ const description = (typeof tool.description === "string" ? tool.description : "Configured MCP tool") +
348
+ (Object.keys(constraints).length ? " Additional argument constraints (validated before execution): " + JSON.stringify(constraints) : "");
323
349
  const base = `${server}_${tool.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "mcp_tool";
324
350
  let name = base;
325
351
  for (let index = 2; registered.has(name); index += 1) {
@@ -327,7 +353,7 @@ export async function mountChatTools(integrations, signal) {
327
353
  name = base.slice(0, 64 - suffix.length) + suffix;
328
354
  }
329
355
  registered.set(name, { client, name: tool.name, schema });
330
- definitions.push({ type: "function", function: { name, description: typeof tool.description === "string" ? tool.description : "Configured MCP tool", parameters: tool.inputSchema } });
356
+ definitions.push({ type: "function", function: { name, description, parameters } });
331
357
  if (Buffer.byteLength(JSON.stringify(definitions)) > CATALOG_BYTES)
332
358
  throw new Error("MCP tool catalog exceeds the 1MB limit");
333
359
  }
@@ -364,12 +390,15 @@ export async function mountChatTools(integrations, signal) {
364
390
  if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== "boolean"))
365
391
  throw new Error("MCP tool returned an invalid result; execution outcome may be uncertain");
366
392
  const parts = [];
393
+ const images = [];
367
394
  let unsupported = 0;
368
395
  for (const item of result.content) {
369
396
  if (!object(item) || typeof item.type !== "string" || (item.type === "text" && typeof item.text !== "string"))
370
397
  throw new Error("MCP tool returned invalid content; execution outcome may be uncertain");
371
398
  if (item.type === "text")
372
399
  parts.push(item.text);
400
+ else if (computerUse && item.type === "image")
401
+ images.push(chatImage(item));
373
402
  else
374
403
  unsupported += 1;
375
404
  }
@@ -377,7 +406,8 @@ export async function mountChatTools(integrations, signal) {
377
406
  parts.push(JSON.stringify(result.structuredContent));
378
407
  if (unsupported)
379
408
  parts.unshift(`[${unsupported} unsupported MCP content item(s) omitted. The operation may have taken effect, but its full result cannot be represented; inspect its state before retrying.]`);
380
- return { text: boundedText(parts.join("\n") || "(empty result)"), ok: result.isError !== true && unsupported === 0 };
409
+ return { text: boundedText(parts.join("\n") || (images.length ? "Screenshot captured." : "(empty result)")), ok: result.isError !== true && unsupported === 0,
410
+ ...(images.length ? { images } : {}) };
381
411
  }
382
412
  catch (error) {
383
413
  await close();