qz-browser-agent 0.1.0 → 0.1.1

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/src/state.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
+ import { BrowserRegistry, browserName, isBrowserId, sameToken, pairingError } from "./browser-registry.mjs";
4
+ import { INSTALL_URL } from "./package-info.mjs";
3
5
 
4
6
  export const PROTOCOL_VERSION = 1;
5
- export const PROJECT_URL = "https://github.com/alibaba/qz-browser-agent";
6
7
  const now = () => Date.now() / 1000;
7
8
  const rounded = value => Math.round(value * 1000) / 1000;
8
9
  const bounded = (value, fallback, min, max) => Number.isFinite(Number(value))
@@ -23,8 +24,11 @@ export class BridgeError extends Error {
23
24
  }
24
25
 
25
26
  export class BridgeState {
26
- constructor({ endpoint = "http://127.0.0.1:18765", requestTimeoutSeconds = 180, connectWaitMs = 2000 } = {}) {
27
+ constructor({ endpoint = "http://127.0.0.1:18765", requestTimeoutSeconds = 180, connectWaitMs = 2000, instanceId = randomUUID(), registry = new BrowserRegistry() } = {}) {
27
28
  this.endpoint = endpoint;
29
+ this.instanceId = instanceId;
30
+ this.registry = registry;
31
+ this.registration = Promise.resolve();
28
32
  this.requestTimeoutSeconds = Math.max(1, requestTimeoutSeconds);
29
33
  this.connectWaitMs = connectWaitMs;
30
34
  this.clients = new Map();
@@ -49,33 +53,118 @@ export class BridgeState {
49
53
  extensionId: client.extensionId || "",
50
54
  name: client.name || "",
51
55
  capabilities: client.capabilities || {},
56
+ paired: Boolean(client.connectionId),
52
57
  })).sort((a, b) => a.clientId.localeCompare(b.clientId));
53
58
  return {
54
59
  endpoint: this.endpoint, protocolVersion: PROTOCOL_VERSION, connected: clients.length > 0,
55
- clientCount: clients.length, clients, pending: this.pending.size, inflight: this.inflight.size,
60
+ clientCount: clients.length, clients, browsers: this.registry.list().map(entry => ({ ...entry, online: clients.some(client => client.clientId === entry.clientId) })), pending: this.pending.size, inflight: this.inflight.size,
56
61
  uptimeSeconds: rounded(now() - this.startedAt),
57
62
  };
58
63
  }
59
64
 
60
- enableSnippet(clientId = "") {
61
- const config = {
62
- enabled: true, endpoint: this.endpoint, pollIntervalMs: 500, pollTimeoutMs: 30000,
63
- requestTimeoutMs: this.requestTimeoutSeconds * 1000, maxBatch: 4, includeCapabilities: true,
64
- ...(clientId ? { clientId } : {}),
65
- };
66
- return `await chrome.storage.local.set({\n "_qingzhou_mcp_bridge": ${JSON.stringify(config, null, 2)}\n});\nawait chrome.storage.local.get("_qingzhou_mcp_bridge");`;
65
+ serializeRegistration(operation) {
66
+ const result = this.registration.then(() => {
67
+ this.closed.signal.throwIfAborted();
68
+ return operation();
69
+ });
70
+ this.registration = result.catch(() => {});
71
+ return result;
72
+ }
73
+
74
+ requireInstance(instanceId) {
75
+ if (instanceId !== this.instanceId) throw pairingError("BRIDGE_INSTANCE_CHANGED", "The local service changed. Check its health and pair again.");
76
+ }
77
+
78
+ revokeClient(id, message = "The browser connection ended. Reconnect that browser before retrying.") {
79
+ const client = this.clients.get(id);
80
+ if (!client) return;
81
+ const error = pairingError("BRIDGE_CLIENT_DISCONNECTED", message);
82
+ this.clients.delete(id);
83
+ client.controller.abort(error);
84
+ for (const request of [...this.pending.values(), ...this.inflight.values()]) {
85
+ if (request.client === client) request.finish(error);
86
+ }
87
+ this.events.emit("change");
88
+ }
89
+
90
+ pair(input) {
91
+ return this.serializeRegistration(async () => {
92
+ this.requireInstance(input.instanceId);
93
+ if (!isBrowserId(input.clientId) || !isBrowserId(input.connectionId)) {
94
+ throw pairingError("INVALID_ARGUMENTS", "clientId and connectionId must be UUIDs.");
95
+ }
96
+ const previous = this.clients.get(input.clientId);
97
+ if (previous && this.liveClients().includes(previous) && previous.connectionId !== input.connectionId) {
98
+ throw Object.assign(pairingError("BRIDGE_CLIENT_IN_USE", "This browser identity has an active connection. Wait for it to disconnect, or connect from a distinct browser profile."), {
99
+ retryAfterMs: Math.max(1, Math.ceil((previous.lastSeenAt + previous.pollLeaseSeconds - now()) * 1000) + 1),
100
+ });
101
+ }
102
+ const entry = await this.registry.pair(input);
103
+ this.closed.signal.throwIfAborted();
104
+ if (previous?.connectionId !== input.connectionId) this.revokeClient(input.clientId);
105
+ const client = this.clients.get(input.clientId) || { id: input.clientId, connectionId: input.connectionId, controller: new AbortController() };
106
+ Object.assign(client, { name: entry.name, lastSeenAt: now(), pollLeaseSeconds: 46 });
107
+ this.clients.set(client.id, client);
108
+ this.events.emit("change");
109
+ return { ok: true, ...entry, connectionId: input.connectionId, instanceId: this.instanceId };
110
+ });
111
+ }
112
+
113
+ authenticatedClient(input) {
114
+ this.requireInstance(input.instanceId);
115
+ const id = input.clientId || input.id;
116
+ const entry = this.registry.entries.get(id), client = this.clients.get(id);
117
+ if (!entry || !sameToken(input.pairingToken, entry.pairingToken) || !client?.connectionId
118
+ || client.connectionId !== input.connectionId) {
119
+ throw pairingError("BRIDGE_PAIRING_REQUIRED", "This browser connection is no longer paired. Connect again from the extension settings.");
120
+ }
121
+ return client;
122
+ }
123
+
124
+ connectionInfo(input) {
125
+ const client = this.authenticatedClient(input);
126
+ return { clientId: client.id, name: client.name, connectionId: client.connectionId, instanceId: this.instanceId };
127
+ }
128
+
129
+ rename(input) {
130
+ return this.serializeRegistration(async () => {
131
+ const client = this.authenticatedClient(input);
132
+ if (!this.liveClients().includes(client)) throw pairingError("BRIDGE_CLIENT_OFFLINE", "Reconnect this browser before renaming it.");
133
+ const entry = await this.registry.pair({ ...input, name: browserName(input.name) });
134
+ this.closed.signal.throwIfAborted();
135
+ client.name = entry.name;
136
+ return { ok: true, ...this.connectionInfo(input) };
137
+ });
138
+ }
139
+
140
+ disconnect(input) {
141
+ return this.serializeRegistration(() => {
142
+ const client = this.authenticatedClient(input);
143
+ this.revokeClient(client.id);
144
+ return { ok: true, clientId: client.id };
145
+ });
146
+ }
147
+
148
+ forgetBrowser(selector) {
149
+ return this.serializeRegistration(async () => {
150
+ const entry = this.registry.get(selector);
151
+ if (!entry) throw pairingError("BRIDGE_CLIENT_NOT_FOUND", `No registered browser named ${selector}. Use qz browsers to list names and UUIDs.`);
152
+ if (this.liveClients().some(client => client.id === entry.clientId)) {
153
+ throw pairingError("BRIDGE_CLIENT_IN_USE", "Disconnect this browser in its extension settings before forgetting its registration.");
154
+ }
155
+ await this.registry.forget(entry.clientId);
156
+ this.revokeClient(entry.clientId);
157
+ return { forgotten: true, clientId: entry.clientId, name: entry.name };
158
+ });
67
159
  }
68
160
 
69
- registerClient(client, timeoutMs = client.pollTimeoutMs || 30000) {
70
- const id = String(client.id || client.clientId || "qingzhou-extension").trim() || "qingzhou-extension";
71
- const info = {
72
- ...client, id, lastSeenAt: now(),
161
+ touchClient(info, client, timeoutMs) {
162
+ Object.assign(info, { lastSeenAt: now(), extensionId: client.extensionId || info.extensionId,
163
+ capabilities: client.capabilities || info.capabilities,
73
164
  pollLeaseSeconds: (bounded(timeoutMs, 30000, 100, 60000)
74
- + bounded(client.pollIntervalMs ?? 750, 750, 100, 60000)) / 1000 + 15,
75
- };
76
- this.clients.set(id, info);
165
+ + bounded(client.pollIntervalMs ?? 750, 750, 100, 60000)) / 1000 + 15 });
166
+ this.clients.set(info.id, info);
77
167
  this.events.emit("change");
78
- return info;
79
168
  }
80
169
 
81
170
  signal(signal) {
@@ -99,25 +188,36 @@ export class BridgeState {
99
188
  });
100
189
  }
101
190
 
102
- async resolveClient(clientId, timeoutMs, signal) {
191
+ async resolveClient(clientId, timeoutMs, signal, returnConnection = false) {
192
+ const registered = clientId && this.registry.get(clientId);
193
+ const target = registered?.clientId || clientId;
103
194
  const deadline = Date.now() + Math.min(timeoutMs, this.connectWaitMs);
104
195
  while (true) {
105
196
  signal.throwIfAborted();
106
197
  const clients = this.liveClients();
107
- if (clientId && clients.some(client => client.id === clientId)) return clientId;
108
- if (!clientId && clients.length === 1) return clients[0].id;
198
+ const selected = target && clients.find(client => client.id === target);
199
+ if (selected) return returnConnection ? selected : selected.id;
200
+ if (clientId && (registered || this.clients.has(target) || Date.now() >= deadline)) throw new BridgeError(registered || this.clients.has(target) ? "BRIDGE_CLIENT_OFFLINE" : "BRIDGE_CLIENT_NOT_FOUND",
201
+ `Browser ${clientId} is ${registered || this.clients.has(target) ? "offline" : "not registered"}. Use qz browsers to inspect browser names and UUIDs.`);
202
+ if (!clientId && clients.length === 1) return returnConnection ? clients[0] : clients[0].id;
109
203
  if (!clientId && clients.length > 1) throw new BridgeError("BRIDGE_CLIENT_REQUIRED",
110
- `Multiple qz browsers are connected. Pass clientId from qingzhou_bridge_status: ${clients.map(client => client.id).sort().join(", ")}`);
204
+ `Multiple qz browsers are connected. Pass clientId from qingzhou_bridge_status: ${clients.map(client => `${client.name || client.id} (${client.id})`).sort().join(", ")}`);
111
205
  if (Date.now() >= deadline) throw new BridgeError("BRIDGE_NOT_CONNECTED",
112
- `No active qz extension${clientId ? ` (${clientId})` : ""}. Open qz in Chrome and accept its data notice, then retry. Install qz: ${PROJECT_URL}`);
206
+ `No active qz extension${clientId ? ` (${clientId})` : ""}. Run qz connect, then confirm the connection in the extension. Install qz: ${INSTALL_URL}`);
113
207
  await this.waitForChange(deadline - Date.now(), signal);
114
208
  }
115
209
  }
116
210
 
117
211
  async poll(client, { timeoutMs = 30000, maxBatch = 4, signal } = {}) {
118
- const activeSignal = this.signal(signal);
212
+ const info = await this.serializeRegistration(() => {
213
+ this.signal(signal).throwIfAborted();
214
+ if (client.pairingVersion !== 1) throw pairingError("BRIDGE_PAIRING_REQUIRED", "Update qz to use the supported pairing protocol.");
215
+ const info = this.authenticatedClient(client);
216
+ this.touchClient(info, client, timeoutMs);
217
+ return info;
218
+ });
219
+ const activeSignal = AbortSignal.any([this.signal(signal), info.controller.signal]);
119
220
  activeSignal.throwIfAborted();
120
- const info = this.registerClient(client, timeoutMs);
121
221
  const deadline = Date.now() + bounded(timeoutMs, 30000, 100, 60000);
122
222
  const limit = Math.floor(bounded(maxBatch, 4, 1, 16));
123
223
  while (true) {
@@ -125,7 +225,7 @@ export class BridgeState {
125
225
  const requests = [];
126
226
  for (const [id, request] of this.pending) {
127
227
  if (requests.length >= limit) break;
128
- if (request.clientId !== info.id) continue;
228
+ if (request.client !== info) continue;
129
229
  this.pending.delete(id);
130
230
  this.inflight.set(id, request);
131
231
  requests.push({ id, method: request.method, params: request.params, timeoutMs: request.timeoutMs });
@@ -139,8 +239,11 @@ export class BridgeState {
139
239
  if (typeof method !== "string" || !method.trim()) throw new BridgeError("INVALID_ARGUMENTS", "method is required");
140
240
  const activeSignal = this.signal(signal);
141
241
  const seconds = bounded(timeoutSeconds || this.requestTimeoutSeconds, this.requestTimeoutSeconds, 1, 86400);
142
- const target = await this.resolveClient(String(clientId || "").trim(), seconds * 1000, activeSignal);
242
+ const client = await this.resolveClient(String(clientId || "").trim(), seconds * 1000, activeSignal, true);
143
243
  activeSignal.throwIfAborted();
244
+ const target = client.id;
245
+ if (this.clients.get(target) !== client || !this.liveClients().includes(client)) throw pairingError("BRIDGE_CLIENT_OFFLINE", "The selected browser disconnected before the request could start.");
246
+ client.controller.signal.throwIfAborted();
144
247
  return new Promise((resolve, reject) => {
145
248
  const id = `qz_${Date.now()}_${randomUUID()}`;
146
249
  const finish = (error, result) => {
@@ -154,7 +257,7 @@ export class BridgeState {
154
257
  const timer = setTimeout(() => finish(new BridgeError("BRIDGE_REQUEST_TIMEOUT",
155
258
  `Request timed out after ${seconds}s: ${method}. A started operation may still be running; check its state before retrying.`)), seconds * 1000);
156
259
  activeSignal.addEventListener("abort", aborted, { once: true });
157
- this.pending.set(id, { id, method, params, clientId: target, timeoutMs: seconds * 1000, finish });
260
+ this.pending.set(id, { id, method, params, clientId: target, client, timeoutMs: seconds * 1000, finish });
158
261
  this.events.emit("change");
159
262
  });
160
263
  }
@@ -162,8 +265,9 @@ export class BridgeState {
162
265
  complete(requestId, payload) {
163
266
  const request = this.inflight.get(requestId);
164
267
  if (!request) return false;
165
- // Legacy v1 clients may omit clientId; an explicit identity must match the dispatched request.
166
- if (payload.clientId && payload.clientId !== request.clientId) return false;
268
+ let client;
269
+ try { client = this.authenticatedClient(payload); } catch { return false; }
270
+ if (client !== request.client) return false;
167
271
  if (payload.ok === false) request.finish(new BridgeError(payload.code || "BRIDGE_TOOL_ERROR", String(payload.error || "qz bridge request failed"), payload.details));
168
272
  else request.finish(null, payload.result);
169
273
  return true;
@@ -181,8 +285,8 @@ export class BridgeState {
181
285
  job.status = "running";
182
286
  job.startedAt = now();
183
287
  try {
184
- // Async jobs outlive their initial MCP call and can be polled with get_tool_job.
185
- job.result = await this.callExtension(method, params, { ...options, signal: undefined });
288
+ // Jobs outlive the initial MCP response, but retain that browser connection's authority.
289
+ job.result = await this.callExtension(method, params, { ...options, signal: options.connectionSignal });
186
290
  job.status = "completed";
187
291
  } catch (error) {
188
292
  job.error = error.message || String(error);
package/src/terminal.mjs CHANGED
@@ -117,11 +117,11 @@ function label(status) { return statusLabels[status] || single(status); }
117
117
  function tone(status) {
118
118
  return ["waiting_user", "waitingUser", "paused", "pending", "interrupted"].includes(status) ? "waiting" : status === "error" ? "error" : "accent";
119
119
  }
120
- function taskCommands(out, id, { cancel = true } = {}) {
120
+ function taskCommands(out, id, { cancel = true, client = "" } = {}) {
121
121
  if (!id) return;
122
122
  out.line();
123
- out.command(`qz show ${commandArgument(id, "TASK_ID")}`);
124
- if (cancel) out.command(`qz cancel ${commandArgument(id, "TASK_ID")}`);
123
+ out.script(`qz show ${commandArgument(id, "TASK_ID")}${client}`);
124
+ if (cancel) out.script(`qz cancel ${commandArgument(id, "TASK_ID")}${client}`);
125
125
  }
126
126
  function latestResponse(snapshot) {
127
127
  const events = snapshot?.transcript?.length ? snapshot.transcript : snapshot?.tail || [];
@@ -212,9 +212,9 @@ function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
212
212
  }
213
213
  }
214
214
 
215
- function taskList(out, sessions) {
215
+ function taskList(out, sessions, client = "") {
216
216
  out.line(`Tasks · ${sessions.length}`, "accent");
217
- if (!sessions.length) { out.line("No tasks yet."); out.command('qz create "Summarize the current page."'); return; }
217
+ if (!sessions.length) { out.line("No tasks yet."); out.script(`qz create "Summarize the current page."${client}`); return; }
218
218
  out.line();
219
219
  const idWidth = Math.max(2, ...sessions.map(task => measure(single(task.id || task.sessionId))));
220
220
  const titleWidth = out.width - idWidth - 18;
@@ -230,7 +230,7 @@ function taskList(out, sessions) {
230
230
  out.field("ID", task.id || task.sessionId);
231
231
  });
232
232
  }
233
- out.line(); out.command("qz show TASK_ID");
233
+ out.line(); out.script(`qz show TASK_ID${client}`);
234
234
  }
235
235
 
236
236
  function resourceList(out, command, result, client) {
@@ -309,7 +309,7 @@ function settingsView(out, result, client) {
309
309
  export function renderResult(parsed, result, options = {}) {
310
310
  const out = output(options);
311
311
  const { command, action } = parsed;
312
- const clientId = parsed.options?.["client-id"] || "";
312
+ const clientId = parsed.presentation?.clientId || result?.clientId || parsed.options?.["client-id"] || "";
313
313
  const client = clientId ? ` --client ${commandArgument(clientId, "CLIENT_ID")}` : "";
314
314
  const presentation = { streamed: parsed.presentation?.streamed === true, clientId };
315
315
  if (command === "watch") snapshotView(out, result?.snapshot || result || {}, presentation);
@@ -407,7 +407,7 @@ export function renderResult(parsed, result, options = {}) {
407
407
  else out.script(`qz show ${commandArgument(result?.sessionId || parsed.id, "TASK_ID")}${client}`);
408
408
  }
409
409
  } else if (command === "tasks") {
410
- if (action === "list" || !action) taskList(out, result?.sessions || []);
410
+ if (action === "list" || !action) taskList(out, result?.sessions || [], client);
411
411
  else if (action === "get") snapshotView(out, result || {}, presentation);
412
412
  else if (action === "create") {
413
413
  if (result?.snapshot) snapshotView(out, result.snapshot, presentation);
@@ -416,31 +416,51 @@ export function renderResult(parsed, result, options = {}) {
416
416
  out.field("Task", result?.session?.id);
417
417
  out.field("Title", single(result?.session?.title));
418
418
  }
419
- taskCommands(out, result?.session?.id || result?.snapshot?.sessionId, { cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
419
+ taskCommands(out, result?.session?.id || result?.snapshot?.sessionId, { client, cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
420
420
  } else if (action === "stop") {
421
421
  out.line(result?.stopped ? "Task stopped" : "Task has no active run", "accent");
422
422
  out.field("Task", result?.sessionId || parsed.id);
423
423
  if (result?.status) out.field("Status", label(result.status));
424
424
  }
425
+ } else if (command === "browsers") {
426
+ if (action === "forget") {
427
+ out.line("Browser registration removed", "accent"); out.field("Name", result.name); out.field("UUID", result.clientId);
428
+ } else {
429
+ const browsers = result.browsers || [];
430
+ out.line(`Browsers · ${browsers.length}`, "accent");
431
+ for (const browser of browsers) { out.line(); out.field("Name", browser.name); out.field("UUID", browser.clientId); out.field("Status", browser.online ? "Connected" : "Offline"); }
432
+ out.line(); out.command('qz create "TASK" --client NAME');
433
+ }
434
+ } else if (command === "connect" && result?.url) {
435
+ out.line(result.opened ? "Browser binding page opened" : "Browser binding URL ready", "accent");
436
+ out.field("URL", result.url);
437
+ out.line("Open this URL in the browser profile you want to connect.");
438
+ out.command("qz browsers");
425
439
  } else if (command === "connect") {
426
440
  out.line(result?.connected ? "Connected to qz" : "Waiting for qz", result?.connected ? "accent" : "waiting");
441
+ const selected = result?.clients?.find(browser => browser.clientId === clientId);
442
+ out.field("Browser", selected?.name);
443
+ out.field("UUID", clientId);
427
444
  out.field("MCP", result?.mcpUrl);
428
- if (result?.connected) out.command('qz create "Summarize the current page."');
445
+ if (result?.connected) out.script(`qz create "Summarize the current page."${client}`);
429
446
  } else if (command === "status") {
430
447
  out.line(result?.connected ? "Connected to qz" : result?.running ? "Service running · browser disconnected" : "Local service stopped", result?.connected ? "accent" : "waiting");
431
448
  out.field("Service", result?.endpoint);
432
449
  if (result?.connected) {
433
450
  out.field("Browsers", result.clientCount ?? result.clients?.length ?? 1);
434
- for (const client of result.clients || []) out.field("Client", client.clientId);
451
+ for (const client of result.clients || []) out.field(client.name || "Client", client.clientId);
435
452
  }
436
- if (result?.updateAvailable) {
453
+ if (result?.updateRequired) {
454
+ out.line("Service upgrade required for browser pairing.", "waiting");
455
+ out.line(result.hint);
456
+ } else if (result?.updateAvailable) {
437
457
  out.line("Service update available. Restart when your current work is finished:", "waiting");
438
458
  out.command("qz stop && qz connect");
439
459
  } else if (!result?.connected) out.command("qz connect");
440
460
  } else if (command === "install") {
441
- out.line(result?.opened ? "Extension download opened" : "Download the qz extension", "accent");
461
+ out.line(result?.opened ? "Chrome Web Store opened" : "Install qz from the Chrome Web Store", "accent");
442
462
  if (!result?.opened) out.line(result?.url);
443
- out.line("Install the extension in Chrome, then connect:"); out.command("qz connect");
463
+ out.line("Click Add to Chrome, then connect:"); out.command("qz connect");
444
464
  } else if (command === "stop") {
445
465
  out.line(result?.stopped ? "Local service stopped" : "Local service is already stopped", "accent");
446
466
  out.command("qz connect");
@@ -470,7 +490,7 @@ export function renderHelp(topic = "", options = {}) {
470
490
  out.command('qz create "Compare these tabs." --title Research --wait');
471
491
  }
472
492
  if (!topic || commandOptions(topic).includes("client")) {
473
- out.line(); out.line("Browser: --client ID (-c)", "muted");
493
+ out.line(); out.line("Browser: --client NAME|UUID (-c)", "muted");
474
494
  }
475
495
  if (!topic || topic === "watch" || commandOptions(topic).includes("wait")) {
476
496
  out.line(); out.line("Task waiting: no default time limit. --timeout SECONDS sets a total deadline, including input pauses.", "muted");
package/src/tools.mjs CHANGED
@@ -13,11 +13,6 @@ const schemaValidator = new AjvJsonSchemaValidator();
13
13
  const definitions = new Map(TOOL_CATALOG.map(tool => [tool.name, {
14
14
  tool, validate: schemaValidator.getValidator(tool.inputSchema),
15
15
  }]));
16
- const skillFallbacks = {
17
- qingzhou_list_skills: { name: "qingzhou_skill_author__list_skills", fields: ["scope"] },
18
- qingzhou_get_skill: { name: "load_skill", fields: ["name", "includeContent", "includeResources"] },
19
- qingzhou_get_skill_resource: { name: "load_skill_resource", fields: ["name", "path", "start", "maxChars"] },
20
- };
21
16
  const taskInputResults = new Set([
22
17
  "qingzhou_get_session_snapshot", "qingzhou_get_session_state", "qingzhou_send_message",
23
18
  "qingzhou_respond_to_user_input", "qingzhou_continue_task", "qingzhou_submit_wait_form", "qingzhou_cancel_wait",
@@ -58,15 +53,23 @@ function argumentsWithDefaults(definition, input) {
58
53
  export async function executeMcpTool(state, name, input = {}, { signal, onTaskUpdate } = {}) {
59
54
  const args = validateMcpToolInput(name, input);
60
55
  if (name === "qingzhou_bridge_status") return state.status();
61
- if (name === "qingzhou_bridge_enable_snippet") return {
62
- snippet: state.enableSnippet(args.clientId), storageKey: "_qingzhou_mcp_bridge",
63
- };
56
+ if (name === "qingzhou_forget_browser") return state.forgetBrowser(args.clientId);
64
57
  if (name === "qingzhou_get_tool_job") return state.getJob(args.jobId, args.includeResult);
58
+ // One MCP operation, including observation, belongs to one connection.
59
+ const connection = await state.resolveClient(String(args.clientId || "").trim(),
60
+ (args.timeoutSeconds || state.requestTimeoutSeconds || 180) * 1000, state.signal(signal), true);
61
+ args.clientId = connection.id;
62
+ const connectionSignal = state.signal(connection.controller.signal);
63
+ const operationSignal = signal ? AbortSignal.any([connectionSignal, signal]) : connectionSignal;
64
+ operationSignal.throwIfAborted();
65
+ const result = await executeBrowserTool(state, name, args, { signal: operationSignal, connectionSignal, onTaskUpdate });
66
+ return args.clientId && (observableTools.has(name) || taskInputResults.has(name) || ["qingzhou_list_sessions", "qingzhou_get_user_input", "qingzhou_stop_session", "qingzhou_abort_session"].includes(name)) && result && typeof result === "object" && !Array.isArray(result)
67
+ ? { ...result, clientId: args.clientId } : result;
68
+ }
69
+
70
+ async function executeBrowserTool(state, name, args, { signal, connectionSignal, onTaskUpdate }) {
65
71
  const observe = observableTools.has(name) && (name === "qingzhou_watch_task" || args.wait || args.stream);
66
72
  const asyncTool = name === "qingzhou_execute_tool" && args.wait === false;
67
- // Resolve once before a mutation or watch so subsequent reads stay in the same browser.
68
- if (observe || asyncTool) args.clientId = await state.resolveClient(args.clientId,
69
- (args.timeoutSeconds || state.requestTimeoutSeconds || 180) * 1000, state.signal(signal));
70
73
  const options = { timeoutSeconds: args.timeoutSeconds, clientId: args.clientId, signal };
71
74
  const startedAt = Date.now();
72
75
  const watch = async (sessionId, tracking = {}) => {
@@ -114,25 +117,11 @@ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUp
114
117
  if (name === "qingzhou_execute_tool") {
115
118
  delete params.wait;
116
119
  params.args ||= {};
117
- return asyncTool ? { ...state.startExtensionJob("execute_tool", params, options), sessionId: args.sessionId, clientId }
120
+ return asyncTool ? { ...state.startExtensionJob("execute_tool", params, { ...options, connectionSignal }), sessionId: args.sessionId, clientId }
118
121
  : state.callExtension("execute_tool", params, options);
119
122
  }
120
123
  const method = name === "qingzhou_abort_session" ? "stop_session" : name.slice("qingzhou_".length);
121
- let result;
122
- try {
123
- result = await state.callExtension(method, params, options);
124
- } catch (error) {
125
- const fallback = skillFallbacks[name];
126
- if (!fallback || !String(error.message).includes("Unknown Qingzhou MCP bridge method")) throw error;
127
- result = await state.callExtension("execute_tool", {
128
- sessionId: "default", name: fallback.name,
129
- args: Object.fromEntries(fallback.fields.map(key => [key, args[key]])), timeoutSeconds: args.timeoutSeconds,
130
- }, options);
131
- }
132
- if (name === "qingzhou_list_skills" && args.query && result && typeof result === "object") {
133
- const names = (result.names || []).filter(item => typeof item === "string" && item.toLowerCase().includes(args.query.toLowerCase()));
134
- if (names.length) result = { ...result, query: args.query, count: names.length, names, items: names.map(item => ({ name: item })) };
135
- }
124
+ let result = await state.callExtension(method, params, options);
136
125
  if (resourceResults.has(name) && result && typeof result.code === "string" && typeof result.error === "string") {
137
126
  throw new BridgeError(result.code, result.error, {
138
127
  ...result.details,