hilos-agent 0.11.16 → 0.11.18
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 +16 -0
- package/package.json +2 -1
- package/skills/hilos-connect/SKILL.md +7 -0
- package/src/config.mjs +3 -2
- package/src/handler.mjs +20 -4
- package/src/run.mjs +27 -4
- package/src/webmcp-bridge.mjs +17 -12
- package/src/webmcp-init.js +61 -40
package/README.md
CHANGED
|
@@ -544,3 +544,19 @@ real tagged release proves OIDC end to end. After that proof, remove the
|
|
|
544
544
|
workflow fallback, revoke the npm token, and delete the GitHub secret (1094,
|
|
545
545
|
1211, 1228). Because the source repository is private, npm will not attach a
|
|
546
546
|
public provenance attestation even when the publish itself uses OIDC.
|
|
547
|
+
|
|
548
|
+
## Browser agents and WebMCP
|
|
549
|
+
|
|
550
|
+
A WebMCP-enabled browser can use the signed-in hilos workspace directly.
|
|
551
|
+
Open Settings → Connections to check page-tool registration. Start with
|
|
552
|
+
`get_hilos_context`, then `navigate_hilos`; Docs, Tasks, rooms, activity and
|
|
553
|
+
onboarding register their own commands. No personal token or plugin is needed
|
|
554
|
+
for that browser session. See [WebMCP setup](https://hilos.sh/docs/webmcp).
|
|
555
|
+
|
|
556
|
+
The plugin and coding-client skills use remote MCP. They do not make Claude
|
|
557
|
+
Code, Codex, or Cursor into browser WebMCP consumers. For background work, keep
|
|
558
|
+
the personal or named-agent MCP connection. The local daemon's optional
|
|
559
|
+
`hilos-agent webmcp` bridge uses an isolated signed-in profile and exact
|
|
560
|
+
person-approved read tools. Its `tools` response distinguishes native from
|
|
561
|
+
limited imperative compatibility mode. Rediscover after navigation; calls bind
|
|
562
|
+
to the inspected page and never retry on site-provided error text.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.18",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"keywords": [
|
|
28
28
|
"hilos",
|
|
29
29
|
"mcp",
|
|
30
|
+
"webmcp",
|
|
30
31
|
"agent",
|
|
31
32
|
"claude-code",
|
|
32
33
|
"codex",
|
|
@@ -5,6 +5,13 @@ description: Connect a named agent to a hilos room with the local daemon. Use wh
|
|
|
5
5
|
|
|
6
6
|
# Connect
|
|
7
7
|
|
|
8
|
+
If the person wants a browser agent to use the hilos page already open, use
|
|
9
|
+
[WebMCP setup](https://hilos.sh/docs/webmcp): sign in in a supported browser,
|
|
10
|
+
check Settings → Connections, then discover `get_hilos_context` and
|
|
11
|
+
`navigate_hilos`. No token or daemon is needed for that page session. Do not
|
|
12
|
+
claim this plugin adds browser WebMCP to a coding client. Personal remote MCP
|
|
13
|
+
uses Settings → Connections; a named local agent uses the steps below.
|
|
14
|
+
|
|
8
15
|
1. Get the private join code from the person or the agent's connection dialog.
|
|
9
16
|
Treat it as a credential. Never paste it into chat, reports, or logs.
|
|
10
17
|
2. From the folder the daemon will work in, run
|
package/src/config.mjs
CHANGED
|
@@ -181,8 +181,9 @@ const DEFAULTS = {
|
|
|
181
181
|
// on timeout we post an honest "taking longer than expected" line.
|
|
182
182
|
chatTimeoutMs: 90000,
|
|
183
183
|
// Work queue: mentions run one at a time — parallel CLI runs on one checkout
|
|
184
|
-
// would collide on git state. queueAcks posts
|
|
185
|
-
//
|
|
184
|
+
// would collide on git state. queueAcks posts a short receipt when a mention
|
|
185
|
+
// lands during a run (replaced by the reply once it starts, 1354); set false
|
|
186
|
+
// to keep it quiet.
|
|
186
187
|
queueAcks: true,
|
|
187
188
|
runTimeoutMs: 600000,
|
|
188
189
|
decisionTimeoutMs: 1800000,
|
package/src/handler.mjs
CHANGED
|
@@ -1509,7 +1509,7 @@ function agentPreamble(workspaceMemory, cfg) {
|
|
|
1509
1509
|
* (threads are where conversations live), else the channel tail. Returns the raw
|
|
1510
1510
|
* rows (for intent routing) and a transcript string (for the prompt).
|
|
1511
1511
|
*/
|
|
1512
|
-
async function fetchContext({ channelId, tool, parentId }) {
|
|
1512
|
+
async function fetchContext({ channelId, tool, parentId, excludeIds = [] }) {
|
|
1513
1513
|
let rows = [];
|
|
1514
1514
|
if (parentId) {
|
|
1515
1515
|
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
@@ -1520,6 +1520,10 @@ async function fetchContext({ channelId, tool, parentId }) {
|
|
|
1520
1520
|
}));
|
|
1521
1521
|
rows = messages;
|
|
1522
1522
|
}
|
|
1523
|
+
// 1354 — this agent's own queue receipt for THIS mention is plumbing, not
|
|
1524
|
+
// conversation: read back as context it became "I'm waiting on a current
|
|
1525
|
+
// task", and the model repeated it as fact.
|
|
1526
|
+
if (excludeIds.length) rows = rows.filter((m) => !excludeIds.includes(m?.id));
|
|
1523
1527
|
// 0779: the rows carry resolved `attachments`, and the body carries the
|
|
1524
1528
|
// unresolvable `` pill token. Flatten the token and
|
|
1525
1529
|
// name the file, so the daemon's chat replies and routing decisions stop
|
|
@@ -1617,7 +1621,8 @@ async function respondConversationally({
|
|
|
1617
1621
|
pathExistsFn = existsSync,
|
|
1618
1622
|
}) {
|
|
1619
1623
|
const implicitDm = message?.implicit === "dm";
|
|
1620
|
-
const { transcript } =
|
|
1624
|
+
const { transcript } =
|
|
1625
|
+
context || (await fetchContext({ channelId, tool, parentId, excludeIds: contextExclusions(message) }));
|
|
1621
1626
|
// 1249 — the room can ask agents to answer in a thread instead of in the
|
|
1622
1627
|
// room. The server owns the setting and marks the mention; the daemon only
|
|
1623
1628
|
// has to post under the message it names. Other mentions retain their
|
|
@@ -1685,7 +1690,13 @@ async function respondConversationally({
|
|
|
1685
1690
|
// If the reply is slow, post ONE "still thinking…" ping and then edit it into
|
|
1686
1691
|
// the answer (single message, no dead air). Needs edit_message; otherwise we
|
|
1687
1692
|
// just post the reply when it's ready. A fast reply never trips the timer.
|
|
1688
|
-
|
|
1693
|
+
// 1354 — a mention that waited in the queue already has such a line: the
|
|
1694
|
+
// queue receipt, posted where the reply goes. Reuse it, so the reply lands
|
|
1695
|
+
// in place of "picking this up now" instead of under it.
|
|
1696
|
+
let thinkingId =
|
|
1697
|
+
testCapabilities.editMessage && message?.queueAckId && replyParentId === (parentId ?? null)
|
|
1698
|
+
? message.queueAckId
|
|
1699
|
+
: null;
|
|
1689
1700
|
let beatStopped = false;
|
|
1690
1701
|
let beatBusy = false;
|
|
1691
1702
|
const beatMs = Math.min(cfg.chatTimeoutMs || 90000, 30000);
|
|
@@ -1768,6 +1779,11 @@ async function respondConversationally({
|
|
|
1768
1779
|
await deliver(body);
|
|
1769
1780
|
}
|
|
1770
1781
|
|
|
1782
|
+
/** Rows a mention's context must not carry: its own queue receipt (1354). */
|
|
1783
|
+
function contextExclusions(message) {
|
|
1784
|
+
return message?.queueAckId ? [message.queueAckId] : [];
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1771
1787
|
/** True when this mention is a request for THIS agent to review a PR (0286/0288). */
|
|
1772
1788
|
export function isReviewRequest(message) {
|
|
1773
1789
|
return (
|
|
@@ -2779,7 +2795,7 @@ export async function handleTask({ message, channelId, tool, me, testCapabilitie
|
|
|
2779
2795
|
// route AND (for a code run) handed to the coding prompt. Context is always
|
|
2780
2796
|
// crucial: a reply like "yeah, do it" only means something against what was
|
|
2781
2797
|
// just said.
|
|
2782
|
-
const context = await fetchContext({ channelId, tool, parentId });
|
|
2798
|
+
const context = await fetchContext({ channelId, tool, parentId, excludeIds: contextExclusions(message) });
|
|
2783
2799
|
const mode = agentMode(message);
|
|
2784
2800
|
|
|
2785
2801
|
// REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
|
package/src/run.mjs
CHANGED
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
|
|
25
25
|
const MAX_IMMEDIATE_SEQUENCE_PAGES = 8;
|
|
26
26
|
|
|
27
|
+
/** The queue receipt a mention gets while another job runs (1354). */
|
|
28
|
+
export const QUEUE_ACK_BODY = "Got it — I'll pick this up as soon as I finish what I'm on.";
|
|
29
|
+
/** What that receipt becomes the moment its job starts (1354). */
|
|
30
|
+
export const QUEUE_ACK_STARTED_BODY = "Picking this up now.";
|
|
31
|
+
|
|
27
32
|
// 1290: the hilos.sh contract. Missing-tool paths exist only for injected tests;
|
|
28
33
|
// tools/list remains the token's prompt inventory and the recall scope check.
|
|
29
34
|
const HILOS_DAEMON_TOOLS = Object.freeze({
|
|
@@ -519,6 +524,15 @@ export async function run(
|
|
|
519
524
|
const withImages = images.files.length
|
|
520
525
|
? { ...message, imageDir: images.dir, images: images.files }
|
|
521
526
|
: message;
|
|
527
|
+
// 1354 — retire the queue receipt as the job starts. The chat path then
|
|
528
|
+
// edits this same message into the reply (one line, in place); a code
|
|
529
|
+
// run leaves it as this truthful "starting" line under its own cards.
|
|
530
|
+
if (message.queueAckId && testCapabilities.editMessage) {
|
|
531
|
+
await taskTool("edit_message", {
|
|
532
|
+
messageId: message.queueAckId,
|
|
533
|
+
body: QUEUE_ACK_STARTED_BODY,
|
|
534
|
+
}).catch(() => {});
|
|
535
|
+
}
|
|
522
536
|
// 0782 — a person pressed Stop on this run's live card. The server already
|
|
523
537
|
// settled the run row (status canceled); this end says so in the room and
|
|
524
538
|
// tears the work down through the SAME cancel path a chat "stop" uses, so a
|
|
@@ -660,11 +674,20 @@ export async function run(
|
|
|
660
674
|
// agent, so "Got it — queued" over a line that may deserve no reply at all
|
|
661
675
|
// is exactly the noise the implicit lane promises not to make.
|
|
662
676
|
if (liveCfg.queueAcks && !r.startedImmediately && !m.deployRequest && !m.implicit) {
|
|
663
|
-
|
|
677
|
+
// 1354 — the ack is a queue receipt, not a status. It used to read
|
|
678
|
+
// "queued behind the current task", and every model in the room (this
|
|
679
|
+
// agent's own included, since the ack sits in its transcript) took the
|
|
680
|
+
// "current task" literally: Ada told Hilo she was "waiting for current
|
|
681
|
+
// task to clear" for an hour over a queue that had drained in seconds,
|
|
682
|
+
// and the work never started. The receipt now says what is true, its id
|
|
683
|
+
// rides on the job, and safeHandle replaces it the moment the job
|
|
684
|
+
// starts, so nothing stale is left for anyone to reason about.
|
|
685
|
+
const ack = await tool("post_message", {
|
|
664
686
|
channelId,
|
|
665
687
|
parentId: m.parentId ?? null,
|
|
666
|
-
body:
|
|
667
|
-
}).catch(() =>
|
|
688
|
+
body: QUEUE_ACK_BODY,
|
|
689
|
+
}).catch(() => null);
|
|
690
|
+
if (ack?.messageId) m.queueAckId = ack.messageId;
|
|
668
691
|
}
|
|
669
692
|
return { accepted: true, settled: false };
|
|
670
693
|
}
|
|
@@ -837,7 +860,7 @@ export async function run(
|
|
|
837
860
|
await tool("post_message", {
|
|
838
861
|
channelId: bridge.binding.channelId,
|
|
839
862
|
parentId: bridge.binding.threadRootId,
|
|
840
|
-
body: "Got it — this
|
|
863
|
+
body: "Got it — this continues in the same local session as soon as I finish what I'm on.",
|
|
841
864
|
}).catch(() => {});
|
|
842
865
|
}
|
|
843
866
|
}
|
package/src/webmcp-bridge.mjs
CHANGED
|
@@ -18,8 +18,8 @@ const AGENT_BROWSER_BIN = "agent-browser/bin/agent-browser.js";
|
|
|
18
18
|
const AGENT_BROWSER_MISSING =
|
|
19
19
|
"WebMCP needs Node.js 24 or newer and agent-browser. Upgrade Node.js, reinstall hilos-agent, then run `agent-browser install`.";
|
|
20
20
|
const MAX_CAPTURE_CHARS = 256 * 1024;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
21
|
+
const MAX_INPUT_BYTES = 16 * 1024;
|
|
22
|
+
const MAX_RESULT_BYTES = 64 * 1024;
|
|
23
23
|
const MAX_ORIGINS = 32;
|
|
24
24
|
const MAX_READ_TOOLS_PER_ORIGIN = 64;
|
|
25
25
|
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
@@ -130,7 +130,8 @@ const SCHEMA_KEYS = new Set([
|
|
|
130
130
|
|
|
131
131
|
/** Strip prose-bearing schema fields so tool poisoning never becomes instructions. */
|
|
132
132
|
export function sanitizeWebMcpSchema(value, depth = 0) {
|
|
133
|
-
if (depth > 12
|
|
133
|
+
if (depth > 12) return undefined;
|
|
134
|
+
if (value == null || typeof value !== "object") return value;
|
|
134
135
|
if (Array.isArray(value)) return value.slice(0, 64).map((item) => sanitizeWebMcpSchema(item, depth + 1));
|
|
135
136
|
const out = {};
|
|
136
137
|
for (const [key, child] of Object.entries(value).slice(0, 256)) {
|
|
@@ -144,6 +145,7 @@ export function sanitizeWebMcpSchema(value, depth = 0) {
|
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
146
147
|
if (!SCHEMA_KEYS.has(key)) continue;
|
|
148
|
+
if (key === "$ref" && (typeof child !== "string" || !child.startsWith("#"))) continue;
|
|
147
149
|
out[key] = sanitizeWebMcpSchema(child, depth + 1);
|
|
148
150
|
}
|
|
149
151
|
return out;
|
|
@@ -293,9 +295,9 @@ function evalResult(run) {
|
|
|
293
295
|
return run?.json?.data?.result;
|
|
294
296
|
}
|
|
295
297
|
|
|
296
|
-
function encodedCall(name, input) {
|
|
297
|
-
const payload = Buffer.from(JSON.stringify({ name, input }), "utf8").toString("base64");
|
|
298
|
-
return `(() => { const
|
|
298
|
+
function encodedCall(name, input, expected) {
|
|
299
|
+
const payload = Buffer.from(JSON.stringify({ name, input, expected }), "utf8").toString("base64");
|
|
300
|
+
return `(() => { const bytes = Uint8Array.from(atob("${payload}"), c => c.charCodeAt(0)); const p = JSON.parse(new TextDecoder().decode(bytes)); return window.__hilosWebMcpBridge.call(p.name, p.input, p.expected); })()`;
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
async function listTools(policy, runner) {
|
|
@@ -315,16 +317,18 @@ async function listTools(policy, runner) {
|
|
|
315
317
|
: "/";
|
|
316
318
|
const registered = Array.isArray(raw?.tools) ? raw.tools : [];
|
|
317
319
|
const allowed = registered
|
|
318
|
-
.filter((tool) => originPolicy.readTools.has(tool?.name))
|
|
320
|
+
.filter((tool) => originPolicy.readTools.has(tool?.name) && !tool.schemaOmitted && tool.annotations?.consequentialHint !== true)
|
|
319
321
|
.map((tool) => ({
|
|
320
322
|
name: tool.name,
|
|
321
|
-
inputSchema:
|
|
323
|
+
inputSchema: sanitizeWebMcpSchema(tool.inputSchema || {}),
|
|
322
324
|
personApprovedRisk: "read",
|
|
323
325
|
siteReadOnlyHint: tool.annotations?.readOnlyHint === true,
|
|
324
326
|
}));
|
|
325
327
|
return {
|
|
326
328
|
ok: true,
|
|
327
329
|
source: { origin, pagePath },
|
|
330
|
+
discoveryId: typeof raw?.discoveryId === "string" ? raw.discoveryId : null,
|
|
331
|
+
mode: raw?.mode === "native" ? "native" : "compatibility",
|
|
328
332
|
tools: allowed,
|
|
329
333
|
blockedToolCount: Math.max(0, registered.length - allowed.length),
|
|
330
334
|
untrusted: true,
|
|
@@ -377,7 +381,7 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
|
|
|
377
381
|
let input;
|
|
378
382
|
try {
|
|
379
383
|
const text = args[1] == null ? "{}" : String(args[1]);
|
|
380
|
-
if (text
|
|
384
|
+
if (Buffer.byteLength(text, "utf8") > MAX_INPUT_BYTES) throw new Error("too large");
|
|
381
385
|
input = JSON.parse(text);
|
|
382
386
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("not an object");
|
|
383
387
|
} catch {
|
|
@@ -395,9 +399,10 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
|
|
|
395
399
|
tool: name,
|
|
396
400
|
};
|
|
397
401
|
}
|
|
402
|
+
if (!available.discoveryId) return { ok: false, code: "stale_bridge", error: "Close and reopen the WebMCP browser to load the current bridge." };
|
|
398
403
|
const run = await runner(
|
|
399
404
|
policy.browserCommand,
|
|
400
|
-
browserArgs(policy, ["eval", encodedCall(name, input)]),
|
|
405
|
+
browserArgs(policy, ["eval", encodedCall(name, input, { ...available.source, discoveryId: available.discoveryId })]),
|
|
401
406
|
);
|
|
402
407
|
if (!run.ok) {
|
|
403
408
|
return {
|
|
@@ -414,12 +419,12 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
|
|
|
414
419
|
if (
|
|
415
420
|
origin !== available.source.origin ||
|
|
416
421
|
resultPagePath !== available.source.pagePath ||
|
|
417
|
-
raw?.tool !== name
|
|
422
|
+
raw?.tool !== name || raw?.discoveryId !== available.discoveryId
|
|
418
423
|
) {
|
|
419
424
|
return { ok: false, code: "provenance_mismatch", error: "The page changed while the WebMCP tool was running; its result was discarded." };
|
|
420
425
|
}
|
|
421
426
|
const resultJson = typeof raw?.resultJson === "string" ? raw.resultJson : "null";
|
|
422
|
-
if (resultJson
|
|
427
|
+
if (Buffer.byteLength(resultJson, "utf8") > MAX_RESULT_BYTES) {
|
|
423
428
|
return { ok: false, code: "result_too_large", error: "The WebMCP tool result exceeded the 64 KB limit." };
|
|
424
429
|
}
|
|
425
430
|
let result;
|
package/src/webmcp-init.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
12
12
|
const MAX_TOOLS = 64;
|
|
13
|
-
const
|
|
14
|
-
const
|
|
13
|
+
const MAX_SCHEMA_BYTES = 32 * 1024;
|
|
14
|
+
const MAX_RESULT_BYTES = 64 * 1024;
|
|
15
15
|
|
|
16
16
|
const copyJson = (value) => {
|
|
17
17
|
if (value === undefined) return undefined;
|
|
@@ -29,12 +29,7 @@
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
function installFallbackModelContext() {
|
|
32
|
-
if (
|
|
33
|
-
document.modelContext &&
|
|
34
|
-
typeof document.modelContext.registerTool === "function" &&
|
|
35
|
-
typeof document.modelContext.getTools === "function" &&
|
|
36
|
-
typeof document.modelContext.executeTool === "function"
|
|
37
|
-
) {
|
|
32
|
+
if (document.modelContext && typeof document.modelContext.registerTool === "function") {
|
|
38
33
|
return document.modelContext;
|
|
39
34
|
}
|
|
40
35
|
|
|
@@ -78,9 +73,13 @@
|
|
|
78
73
|
throw options.signal.reason || domError("Registration aborted", "AbortError");
|
|
79
74
|
}
|
|
80
75
|
|
|
76
|
+
if (tools.size >= MAX_TOOLS) throw new RangeError("Too many WebMCP tools");
|
|
81
77
|
const inputSchema = copyJson(tool.inputSchema);
|
|
78
|
+
if (inputSchema !== undefined && (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema) || (inputSchema.type && inputSchema.type !== "object"))) {
|
|
79
|
+
throw new TypeError("WebMCP input schema must describe an object");
|
|
80
|
+
}
|
|
82
81
|
const schemaText = JSON.stringify(inputSchema ?? {});
|
|
83
|
-
if (schemaText.length >
|
|
82
|
+
if (new TextEncoder().encode(schemaText).length > MAX_SCHEMA_BYTES) {
|
|
84
83
|
throw new TypeError("WebMCP input schema is too large");
|
|
85
84
|
}
|
|
86
85
|
const entry = {
|
|
@@ -92,6 +91,7 @@
|
|
|
92
91
|
? {
|
|
93
92
|
readOnlyHint: tool.annotations.readOnlyHint === true,
|
|
94
93
|
untrustedContentHint: tool.annotations.untrustedContentHint === true,
|
|
94
|
+
consequentialHint: tool.annotations.consequentialHint === true,
|
|
95
95
|
}
|
|
96
96
|
: undefined,
|
|
97
97
|
execute: tool.execute,
|
|
@@ -117,7 +117,10 @@
|
|
|
117
117
|
}
|
|
118
118
|
},
|
|
119
119
|
|
|
120
|
-
async getTools() {
|
|
120
|
+
async getTools(options = {}) {
|
|
121
|
+
if (options.fromOrigins?.some((origin) => origin !== location.origin)) {
|
|
122
|
+
throw domError("The hilos compatibility bridge supports this document only", "NotSupportedError");
|
|
123
|
+
}
|
|
121
124
|
return [...tools.values()]
|
|
122
125
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
123
126
|
.map((tool) => ({
|
|
@@ -142,12 +145,26 @@
|
|
|
142
145
|
const tool = tools.get(name);
|
|
143
146
|
if (!tool) throw domError(`WebMCP tool ${name} is not registered`, "NotFoundError");
|
|
144
147
|
|
|
148
|
+
if (registeredTool.origin !== location.origin || registeredTool.window !== window) {
|
|
149
|
+
throw domError("WebMCP tool belongs to another document", "SecurityError");
|
|
150
|
+
}
|
|
145
151
|
const controller = new AbortController();
|
|
146
|
-
|
|
152
|
+
let rejectAbort;
|
|
153
|
+
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
|
154
|
+
const onAbort = () => {
|
|
155
|
+
controller.abort(options.signal?.reason);
|
|
156
|
+
rejectAbort(controller.signal.reason);
|
|
157
|
+
};
|
|
147
158
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
148
159
|
try {
|
|
149
|
-
const value = await
|
|
150
|
-
|
|
160
|
+
const value = await Promise.race([
|
|
161
|
+
Promise.resolve().then(() => {
|
|
162
|
+
controller.signal.throwIfAborted();
|
|
163
|
+
return tool.execute(copyJson(inputObject), { signal: controller.signal });
|
|
164
|
+
}),
|
|
165
|
+
aborted,
|
|
166
|
+
]);
|
|
167
|
+
return JSON.stringify(value) ?? "null";
|
|
151
168
|
} finally {
|
|
152
169
|
options.signal?.removeEventListener("abort", onAbort);
|
|
153
170
|
}
|
|
@@ -179,7 +196,11 @@
|
|
|
179
196
|
return context;
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
const native = Boolean(document.modelContext?.registerTool);
|
|
182
200
|
const modelContext = installFallbackModelContext();
|
|
201
|
+
let epoch = 0;
|
|
202
|
+
let discovery = null;
|
|
203
|
+
modelContext.addEventListener?.("toolchange", () => { epoch++; });
|
|
183
204
|
|
|
184
205
|
const source = () => ({
|
|
185
206
|
origin: location.origin,
|
|
@@ -187,24 +208,30 @@
|
|
|
187
208
|
});
|
|
188
209
|
|
|
189
210
|
async function registeredTools() {
|
|
211
|
+
if (typeof modelContext.getTools !== "function" || typeof modelContext.executeTool !== "function") {
|
|
212
|
+
throw domError("This native WebMCP build has no in-page consumer API. Update the browser; hilos will not replace its native provider.", "NotSupportedError");
|
|
213
|
+
}
|
|
190
214
|
const tools = await modelContext.getTools();
|
|
191
215
|
return (Array.isArray(tools) ? tools : [])
|
|
192
|
-
.filter((tool) => tool && tool.origin === location.origin)
|
|
216
|
+
.filter((tool) => tool && tool.origin === location.origin && tool.window === window)
|
|
193
217
|
.slice(0, MAX_TOOLS);
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
const bridge = Object.freeze({
|
|
197
221
|
version: 1,
|
|
198
|
-
mode:
|
|
199
|
-
document.modelContext === modelContext &&
|
|
200
|
-
Object.prototype.hasOwnProperty.call(document, "modelContext")
|
|
201
|
-
? "polyfill"
|
|
202
|
-
: "native",
|
|
222
|
+
mode: native ? "native" : "compatibility",
|
|
203
223
|
|
|
204
224
|
async list() {
|
|
225
|
+
const before = { href: location.href, epoch };
|
|
205
226
|
const tools = await registeredTools();
|
|
227
|
+
if (before.href !== location.href || before.epoch !== epoch) {
|
|
228
|
+
throw domError("The page changed during discovery. List its tools again.", "InvalidStateError");
|
|
229
|
+
}
|
|
230
|
+
discovery = { id: crypto.randomUUID(), href: before.href, epoch: before.epoch, tools };
|
|
206
231
|
return {
|
|
207
232
|
...source(),
|
|
233
|
+
discoveryId: discovery.id,
|
|
234
|
+
mode: native ? "native" : "compatibility",
|
|
208
235
|
tools: tools.map((tool) => {
|
|
209
236
|
let inputSchema = tool.inputSchema;
|
|
210
237
|
let schemaOmitted = false;
|
|
@@ -213,7 +240,7 @@
|
|
|
213
240
|
// schema as the draft's serialized JSON string; the current report
|
|
214
241
|
// exposes an object. Normalize both without passing site prose on.
|
|
215
242
|
if (typeof inputSchema === "string") inputSchema = JSON.parse(inputSchema);
|
|
216
|
-
if (JSON.stringify(inputSchema ?? {}).length >
|
|
243
|
+
if (new TextEncoder().encode(JSON.stringify(inputSchema ?? {})).length > MAX_SCHEMA_BYTES) {
|
|
217
244
|
inputSchema = undefined;
|
|
218
245
|
schemaOmitted = true;
|
|
219
246
|
}
|
|
@@ -229,6 +256,7 @@
|
|
|
229
256
|
? {
|
|
230
257
|
readOnlyHint: tool.annotations.readOnlyHint === true,
|
|
231
258
|
untrustedContentHint: tool.annotations.untrustedContentHint === true,
|
|
259
|
+
consequentialHint: tool.annotations.consequentialHint === true,
|
|
232
260
|
}
|
|
233
261
|
: undefined,
|
|
234
262
|
};
|
|
@@ -236,9 +264,11 @@
|
|
|
236
264
|
};
|
|
237
265
|
},
|
|
238
266
|
|
|
239
|
-
async call(name, inputObject, timeoutMs = 30_000) {
|
|
240
|
-
|
|
241
|
-
|
|
267
|
+
async call(name, inputObject, expected, timeoutMs = 30_000) {
|
|
268
|
+
if (!expected || expected.origin !== location.origin || expected.pagePath !== location.pathname || !discovery || expected.discoveryId !== discovery.id || discovery.href !== location.href || discovery.epoch !== epoch) {
|
|
269
|
+
throw domError("The page or tool inventory changed. Discover tools again before calling.", "InvalidStateError");
|
|
270
|
+
}
|
|
271
|
+
const tool = discovery.tools.find((candidate) => candidate.name === name);
|
|
242
272
|
if (!tool) throw domError(`WebMCP tool ${name} is not registered`, "NotFoundError");
|
|
243
273
|
|
|
244
274
|
const controller = new AbortController();
|
|
@@ -247,25 +277,16 @@
|
|
|
247
277
|
Math.max(1_000, Math.min(Number(timeoutMs) || 30_000, 60_000)),
|
|
248
278
|
);
|
|
249
279
|
try {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// retry the pre-execution parse failure; an arbitrary tool failure
|
|
258
|
-
// might follow a side effect and must never be executed twice.
|
|
259
|
-
if (!/failed to parse input arguments/i.test(String(error?.message || error))) throw error;
|
|
260
|
-
result = await modelContext.executeTool(tool, JSON.stringify(inputObject), {
|
|
261
|
-
signal: controller.signal,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
const resultJson = typeof result === "string" ? result : JSON.stringify(result);
|
|
265
|
-
if (resultJson.length > MAX_RESULT_CHARS) {
|
|
280
|
+
// Never infer a protocol version from a site's error message and retry:
|
|
281
|
+
// that error may have followed a side effect.
|
|
282
|
+
const result = await modelContext.executeTool(tool, inputObject, {
|
|
283
|
+
signal: controller.signal,
|
|
284
|
+
});
|
|
285
|
+
const resultJson = (typeof result === "string" ? result : JSON.stringify(result)) ?? "null";
|
|
286
|
+
if (new TextEncoder().encode(resultJson).length > MAX_RESULT_BYTES) {
|
|
266
287
|
throw new RangeError("WebMCP tool result exceeds the 64 KB bridge limit");
|
|
267
288
|
}
|
|
268
|
-
return { ...source(), tool: name, resultJson };
|
|
289
|
+
return { ...source(), discoveryId: expected.discoveryId, tool: name, resultJson };
|
|
269
290
|
} finally {
|
|
270
291
|
clearTimeout(timer);
|
|
271
292
|
}
|