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.
- package/README.md +2 -2
- package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-BTiuL79F.js} +1 -1
- package/dist/assets/{arc-DW7mrI4p.js → arc-C88Blc2v.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-BZsJIZrb.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CVeMe9Ah.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-Cedk3Bhx.js} +1 -1
- package/dist/assets/channel-taCNRlUU.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-D-VRqzTB.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-BChov01Y.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-C-TfwvHy.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-Db7CGj5Y.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-CNvIfqKT.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-C_bEp5tq.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Coi4BOls.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-CzylEuL3.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-m7p7ztaD.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BMy6o9x2.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-BDQ__0_J.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Djv4ghUb.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-BULrlGYH.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-D-jrT_hU.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-p2UhQmfL.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-DHvGWZ7q.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-ChhKDot-.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-B-T7A43R.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-CJm8ENNY.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-510tlza8.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-PkvRPHel.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-BVBVPoX3.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BgYMVm7L.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-BGqsnc_3.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-D_-UeReH.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-D0uooONx.js} +1 -1
- package/dist/assets/index-BwdMAQ6p.css +1 -0
- package/dist/assets/index-ByJennr7.js +581 -0
- package/dist/assets/{index-h9FFNwTu.js → index-zRsJERGJ.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-B9e1z77f.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-BB4vNwvD.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DpkosOTB.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-DbDP4kUu.js} +1 -1
- package/dist/assets/{linear-3su2TG93.js → linear-DBJGPmjT.js} +1 -1
- package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-DWPRi-ue.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-nszBbJmx.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-CWykJWu_.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-g3Da-Vnv.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-Wzrlpzgh.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-moHTFMC0.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-CwfXdFH8.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-3ZROYvnq.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-E1TBebP8.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-DHKEU5pu.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BguEj09D.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-C7IDYnhd.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-DCW-F5PW.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-xBMFtWYV.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-B5nIkuH8.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-DYFAhdbr.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-DeXwX6Cz.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +2 -1
- package/dist-server/container-mcp.js +1029 -164
- package/dist-server/drivers/agents-proxy.js +64 -10
- package/dist-server/drivers/phone-proxy.js +33 -2
- package/dist-server/index.js +14577 -9001
- package/dist-server/local-computer-proxy.js +3 -3
- package/dist-server/local-computer.js +53 -17
- package/dist-server/openmausbot.js +3981 -1165
- package/dist-server/pair-cli.js +3981 -1165
- package/dist-server/permission-proxy.js +29 -9
- package/dist-server/server/attachments.js +55 -0
- package/dist-server/server/auto-approve.js +4 -3
- package/dist-server/server/auto-vm-claims.js +1 -0
- package/dist-server/server/bot-package.js +18 -292
- package/dist-server/server/bot-profile.js +5 -17
- package/dist-server/server/box-create-idempotency.js +3 -3
- package/dist-server/server/box.js +57 -55
- package/dist-server/server/browser-proxy.js +3 -2
- package/dist-server/server/browser-runtime.js +37 -1
- package/dist-server/server/cli.js +2 -2
- package/dist-server/server/command-allowlist.js +154 -0
- package/dist-server/server/computer-wait.js +22 -9
- package/dist-server/server/config.js +59 -9
- package/dist-server/server/connector-verdict.js +141 -0
- package/dist-server/server/container-computer.js +1 -1
- package/dist-server/server/default-model-selection.js +9 -0
- package/dist-server/server/delegations.js +85 -34
- package/dist-server/server/drivers/acp/core.js +57 -15
- package/dist-server/server/drivers/agents-call.js +42 -3
- package/dist-server/server/drivers/agents-catalog.js +34 -4
- package/dist-server/server/drivers/boxagent.js +174 -36
- package/dist-server/server/drivers/builtIn.js +2 -0
- package/dist-server/server/drivers/chat-box-tools.js +90 -0
- package/dist-server/server/drivers/chat-images.js +54 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
- package/dist-server/server/drivers/chat-tool-approval.js +70 -18
- package/dist-server/server/drivers/claude.js +7 -2
- package/dist-server/server/drivers/codex-instructions.js +48 -6
- package/dist-server/server/drivers/codex-tool-surface.js +20 -0
- package/dist-server/server/drivers/codex.js +95 -30
- package/dist-server/server/drivers/mistral.js +115 -0
- package/dist-server/server/drivers/openai-chat.js +155 -32
- package/dist-server/server/drivers/openai-compat.js +1 -0
- package/dist-server/server/drivers/permission-command.js +35 -0
- package/dist-server/server/drivers/phone-proxy.js +47 -2
- package/dist-server/server/drivers/pi.js +75 -4
- package/dist-server/server/drivers/prompt-split.js +87 -0
- package/dist-server/server/fleet.js +1 -1
- package/dist-server/server/group-thread-usage.js +103 -0
- package/dist-server/server/harness/registry.js +1 -0
- package/dist-server/server/hosted-models.js +1 -1
- package/dist-server/server/index.js +1385 -322
- package/dist-server/server/local-computer.js +72 -23
- package/dist-server/server/managed-desktop.js +1 -1
- package/dist-server/server/mcp-bridge.js +6 -6
- package/dist-server/server/new-bot-defaults.js +115 -0
- package/dist-server/server/org-library.js +1051 -0
- package/dist-server/server/package-export.js +627 -39
- package/dist-server/server/package-import.js +458 -0
- package/dist-server/server/package-parts.js +87 -0
- package/dist-server/server/peer-approval.js +16 -7
- package/dist-server/server/peer-roster.js +10 -0
- package/dist-server/server/presets.js +492 -0
- package/dist-server/server/provider-key-check.js +3 -2
- package/dist-server/server/published-teams.js +80 -0
- package/dist-server/server/room-handoffs.js +19 -3
- package/dist-server/server/routes/bot-presets.js +25 -0
- package/dist-server/server/routine-requests.js +6 -3
- package/dist-server/server/routines.js +41 -1
- package/dist-server/server/section-context.js +10 -0
- package/dist-server/server/skills.js +34 -61
- package/dist-server/server/steer-queue.js +3 -2
- package/dist-server/server/store.js +259 -15
- package/dist-server/server/surface.js +10 -4
- package/dist-server/server/system-prompt.js +12 -4
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +10 -0
- package/dist-server/server/team-library.js +6 -2
- package/dist-server/server/team-manifest.js +4 -3
- package/dist-server/server/team-setup-requests.js +16 -5
- package/dist-server/server/thread-events.js +17 -1
- package/dist-server/server/turn-context.js +2 -0
- package/dist-server/server/turn-watchdog.js +12 -3
- package/dist-server/server/usage-ledger.js +12 -1
- package/dist-server/server/vps-computer.js +139 -51
- package/dist-server/server/vps-container-mcp.js +7 -0
- package/dist-server/server/vps-ssh.js +36 -7
- package/dist-server/server/workspace-backup-policy.js +12 -0
- package/dist-server/server/workspace-backup.js +2 -2
- package/dist-server/server/workspace.js +1 -1
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/ask-question.js +188 -12
- package/dist-server/shared/bot-profile.js +15 -0
- package/dist-server/shared/command-allowlist.js +1 -0
- package/dist-server/shared/credential-request.js +1 -1
- package/dist-server/shared/package-format.js +967 -0
- package/dist-server/shared/skill-md.js +64 -0
- package/dist-server/shared/team-backup.js +7 -0
- package/dist-server/shared/wire.js +4 -0
- package/dist-server/vps-container-mcp.js +1129 -170
- package/enterprise/README.md +1 -1
- package/package.json +1 -1
- package/dist/assets/channel-BXFkgus9.js +0 -1
- package/dist/assets/index-BGgqlWPJ.css +0 -1
- package/dist/assets/index-BtHo8ZKl.js +0 -577
- 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
|
|
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 {
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
192
|
-
return;
|
|
317
|
+
return await finishRun(!failed, failed ? kind : null);
|
|
193
318
|
}
|
|
194
319
|
}
|
|
195
|
-
if (
|
|
196
|
-
const status = await api(`/boxes/${boxId}/prompts/${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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) >
|
|
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) >
|
|
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) >
|
|
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
|
-
|
|
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
|
|
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();
|