qz-browser-agent 0.1.0 → 0.1.2

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/server.mjs CHANGED
@@ -2,11 +2,13 @@ import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
5
- import { BridgeState, PROTOCOL_VERSION, PROJECT_URL } from "./state.mjs";
5
+ import { BridgeState, PROTOCOL_VERSION } from "./state.mjs";
6
+ import { INSTALL_URL } from "./package-info.mjs";
6
7
  import { createMcpServer } from "./tools.mjs";
8
+ import { BrowserRegistry } from "./browser-registry.mjs";
7
9
 
8
10
  const MAX_BODY_BYTES = 32 * 1024 * 1024;
9
- const CONNECT_PAGE = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect qz</title><style>body{font:16px/1.6 system-ui,sans-serif;max-width:38rem;margin:15vh auto;padding:24px;background:#fafafa;color:#202020}h1{font-size:28px;line-height:1.2}a{color:inherit}p{color:#555}</style><h1>Connect qz</h1><p data-qz-connection-status>Connecting to the qz browser extension…</p><p>Keep this page open while qz connects. If qz is not installed, <a href="${PROJECT_URL}" target="_blank" rel="noopener noreferrer">install qz from GitHub</a>.</p></html>`;
11
+ const CONNECT_PAGE = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect qz</title><style>body{font:16px/1.6 system-ui,sans-serif;max-width:38rem;margin:15vh auto;padding:24px;background:#fafafa;color:#202020}h1{font-size:28px;line-height:1.2}a{color:inherit}p{color:#555}</style><h1>Connect qz</h1><p data-qz-connection-status>Connecting to the qz browser extension…</p><p>Keep this page open while qz connects. If qz is not installed, <a href="${INSTALL_URL}" target="_blank" rel="noopener noreferrer">install qz from the Chrome Web Store</a>.</p></html>`;
10
12
 
11
13
  function sendJson(response, status, body, headers = {}) {
12
14
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
@@ -31,11 +33,11 @@ async function readJson(request) {
31
33
  }
32
34
 
33
35
  export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId = randomUUID(), scopeId = "", buildId = "", onClose,
34
- requestTimeoutSeconds = 180, connectWaitMs = 2000, mcpSessionIdleTimeoutMs = 30 * 60 * 1000 } = {}) {
36
+ registryPath, registry, requestTimeoutSeconds = 180, connectWaitMs = 2000, mcpSessionIdleTimeoutMs = 30 * 60 * 1000 } = {}) {
35
37
  if (!["127.0.0.1", "localhost"].includes(host)) throw new Error("qz bridge must bind to loopback");
36
38
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid bridge port");
37
39
  if (!Number.isFinite(mcpSessionIdleTimeoutMs) || mcpSessionIdleTimeoutMs <= 0) throw new Error("Invalid MCP session idle timeout");
38
- const state = new BridgeState({ requestTimeoutSeconds, connectWaitMs });
40
+ const state = new BridgeState({ requestTimeoutSeconds, connectWaitMs, instanceId, registry: registry || await BrowserRegistry.open(registryPath) });
39
41
  const sessions = new Map();
40
42
  const mcpResponses = new Set();
41
43
  let actualPort;
@@ -76,6 +78,7 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
76
78
  response.once("close", disconnected);
77
79
  };
78
80
  const server = createServer(async (request, response) => {
81
+ let cors = {};
79
82
  try {
80
83
  if (closing) return sendJson(response, 503, { error: "The local bridge is stopping" });
81
84
  if (![ `127.0.0.1:${actualPort}`, `localhost:${actualPort}` ].includes(String(request.headers.host || "").toLowerCase())) {
@@ -87,7 +90,7 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
87
90
  if (origin && (path === "/mcp" || !/^chrome-extension:\/\/[a-p]{32}$/.test(origin))) {
88
91
  return sendJson(response, 403, { error: "invalid bridge origin" });
89
92
  }
90
- const cors = origin ? {
93
+ cors = origin ? {
91
94
  "access-control-allow-origin": origin, vary: "Origin",
92
95
  "access-control-allow-methods": "GET, POST, OPTIONS", "access-control-allow-headers": "content-type",
93
96
  } : {};
@@ -96,7 +99,7 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
96
99
  return response.end();
97
100
  }
98
101
  if (request.method === "GET" && path === "/health") return sendJson(response, 200, {
99
- ok: true, service: "qz-browser-agent", protocolVersion: PROTOCOL_VERSION, instanceId, scopeId, buildId, processId: process.pid, status: state.status(),
102
+ ok: true, service: "qz-browser-agent", protocolVersion: PROTOCOL_VERSION, features: { browserPairing: 1 }, instanceId, scopeId, buildId, processId: process.pid, status: state.status(),
100
103
  }, cors);
101
104
  if (request.method === "GET" && path === "/qz-connect") {
102
105
  response.writeHead(200, {
@@ -145,6 +148,10 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
145
148
  await session.transport.handleRequest(request, response, body);
146
149
  return;
147
150
  }
151
+ if (request.method === "POST" && ["/v1/qingzhou/pair", "/v1/qingzhou/rename", "/v1/qingzhou/disconnect"].includes(path)) {
152
+ const body = await readJson(request);
153
+ return sendJson(response, 200, await state[path.split("/").at(-1)](body), cors);
154
+ }
148
155
  if (request.method === "POST" && ["/v1/qingzhou/poll", "/v1/qingzhou/result"].includes(path)) {
149
156
  const body = await readJson(request);
150
157
  if (path.endsWith("/poll")) {
@@ -157,7 +164,7 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
157
164
  timeoutMs: client.pollTimeoutMs || body.pollTimeoutMs || 30000,
158
165
  maxBatch: client.maxBatch || body.maxBatch || 4, signal: controller.signal,
159
166
  });
160
- if (!response.destroyed) sendJson(response, 200, { requests }, cors);
167
+ if (!response.destroyed) sendJson(response, 200, { requests, ...state.connectionInfo(client) }, cors);
161
168
  } finally {
162
169
  response.off("close", disconnected);
163
170
  }
@@ -169,7 +176,10 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
169
176
  }
170
177
  sendJson(response, 404, { error: "not found" }, cors);
171
178
  } catch (error) {
172
- if (!response.destroyed && !response.headersSent) sendJson(response, error.status || 500, { ok: false, error: error.message });
179
+ if (!response.destroyed && !response.headersSent) sendJson(response, error.status || (error.code ? 409 : 500), {
180
+ ok: false, ...(error.code ? { code: error.code } : {}), error: error.message,
181
+ ...(error.code === "BRIDGE_CLIENT_IN_USE" && Number.isFinite(error.retryAfterMs) ? { retryAfterMs: error.retryAfterMs } : {}),
182
+ }, cors);
173
183
  else if (!response.destroyed) response.end();
174
184
  }
175
185
  });
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);
@@ -5,6 +5,8 @@ const ACTIVE_STATUSES = new Set(["pending", "running", "waiting_user", "paused"]
5
5
  const INPUT_STATUSES = new Set(["waiting_user", "paused"]);
6
6
  const record = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7
7
 
8
+ export const TASK_NOT_FOUND_MESSAGE = "The task was not found in the selected browser. It may have been deleted, the task ID may be incorrect, or a different browser may be selected.";
9
+
8
10
  function taskStatus(snapshot) {
9
11
  const status = String(snapshot.status || snapshot.runtime?.status || snapshot.state?.status || "").trim();
10
12
  if (status === "waitingUser") return "waiting_user";
@@ -55,8 +57,8 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
55
57
  if (deadline !== null && Date.now() >= deadline) controller.abort(timeout);
56
58
  activeSignal.throwIfAborted();
57
59
  };
58
- const pollingDelay = () => delay(deadline === null ? pollIntervalMs : Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())),
59
- undefined, { signal: activeSignal });
60
+ // The total deadline cancels this sleep; shortening it can wake before that deadline.
61
+ const pollingDelay = () => delay(pollIntervalMs, undefined, { signal: activeSignal });
60
62
  const readSnapshot = async () => {
61
63
  checkActive();
62
64
  const milliseconds = Math.max(1, Math.ceil(requestTimeoutSeconds * 1000));
@@ -122,7 +124,7 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
122
124
  throw Object.assign(new Error("The task snapshot belongs to a different session."), { code: "task_changed" });
123
125
  }
124
126
  if (Object.hasOwn(snapshot, "session") && snapshot.session === null) {
125
- throw Object.assign(new Error("The task was deleted while waiting for its result."), { code: "task_deleted" });
127
+ throw Object.assign(new Error(TASK_NOT_FOUND_MESSAGE), { code: "task_deleted" });
126
128
  }
127
129
  const runtime = record(snapshot.runtime);
128
130
  const canonicalInput = Object.hasOwn(snapshot, "input") ? record(snapshot.input) : null;
package/src/terminal.mjs CHANGED
@@ -7,7 +7,9 @@ const palette = { accent: 36, muted: 90, error: 31, waiting: 33 };
7
7
 
8
8
  // Remote pages and model responses are text, never terminal instructions.
9
9
  export function sanitizeTerminalText(value) {
10
- return stripVTControlCharacters(String(value ?? ""))
10
+ // Early Node 22 versions do not strip OSC strings terminated by ESC + backslash.
11
+ const text = String(value ?? "").replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/g, "");
12
+ return stripVTControlCharacters(text)
11
13
  .replace(/\r\n?/g, "\n")
12
14
  .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "")
13
15
  .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
@@ -117,11 +119,11 @@ function label(status) { return statusLabels[status] || single(status); }
117
119
  function tone(status) {
118
120
  return ["waiting_user", "waitingUser", "paused", "pending", "interrupted"].includes(status) ? "waiting" : status === "error" ? "error" : "accent";
119
121
  }
120
- function taskCommands(out, id, { cancel = true } = {}) {
122
+ function taskCommands(out, id, { cancel = true, client = "" } = {}) {
121
123
  if (!id) return;
122
124
  out.line();
123
- out.command(`qz show ${commandArgument(id, "TASK_ID")}`);
124
- if (cancel) out.command(`qz cancel ${commandArgument(id, "TASK_ID")}`);
125
+ out.script(`qz show ${commandArgument(id, "TASK_ID")}${client}`);
126
+ if (cancel) out.script(`qz cancel ${commandArgument(id, "TASK_ID")}${client}`);
125
127
  }
126
128
  function latestResponse(snapshot) {
127
129
  const events = snapshot?.transcript?.length ? snapshot.transcript : snapshot?.tail || [];
@@ -212,9 +214,9 @@ function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
212
214
  }
213
215
  }
214
216
 
215
- function taskList(out, sessions) {
217
+ function taskList(out, sessions, client = "") {
216
218
  out.line(`Tasks · ${sessions.length}`, "accent");
217
- if (!sessions.length) { out.line("No tasks yet."); out.command('qz create "Summarize the current page."'); return; }
219
+ if (!sessions.length) { out.line("No tasks yet."); out.script(`qz create "Summarize the current page."${client}`); return; }
218
220
  out.line();
219
221
  const idWidth = Math.max(2, ...sessions.map(task => measure(single(task.id || task.sessionId))));
220
222
  const titleWidth = out.width - idWidth - 18;
@@ -230,7 +232,7 @@ function taskList(out, sessions) {
230
232
  out.field("ID", task.id || task.sessionId);
231
233
  });
232
234
  }
233
- out.line(); out.command("qz show TASK_ID");
235
+ out.line(); out.script(`qz show TASK_ID${client}`);
234
236
  }
235
237
 
236
238
  function resourceList(out, command, result, client) {
@@ -309,7 +311,7 @@ function settingsView(out, result, client) {
309
311
  export function renderResult(parsed, result, options = {}) {
310
312
  const out = output(options);
311
313
  const { command, action } = parsed;
312
- const clientId = parsed.options?.["client-id"] || "";
314
+ const clientId = parsed.presentation?.clientId || result?.clientId || parsed.options?.["client-id"] || "";
313
315
  const client = clientId ? ` --client ${commandArgument(clientId, "CLIENT_ID")}` : "";
314
316
  const presentation = { streamed: parsed.presentation?.streamed === true, clientId };
315
317
  if (command === "watch") snapshotView(out, result?.snapshot || result || {}, presentation);
@@ -407,7 +409,7 @@ export function renderResult(parsed, result, options = {}) {
407
409
  else out.script(`qz show ${commandArgument(result?.sessionId || parsed.id, "TASK_ID")}${client}`);
408
410
  }
409
411
  } else if (command === "tasks") {
410
- if (action === "list" || !action) taskList(out, result?.sessions || []);
412
+ if (action === "list" || !action) taskList(out, result?.sessions || [], client);
411
413
  else if (action === "get") snapshotView(out, result || {}, presentation);
412
414
  else if (action === "create") {
413
415
  if (result?.snapshot) snapshotView(out, result.snapshot, presentation);
@@ -416,31 +418,51 @@ export function renderResult(parsed, result, options = {}) {
416
418
  out.field("Task", result?.session?.id);
417
419
  out.field("Title", single(result?.session?.title));
418
420
  }
419
- taskCommands(out, result?.session?.id || result?.snapshot?.sessionId, { cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
421
+ taskCommands(out, result?.session?.id || result?.snapshot?.sessionId, { client, cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
420
422
  } else if (action === "stop") {
421
423
  out.line(result?.stopped ? "Task stopped" : "Task has no active run", "accent");
422
424
  out.field("Task", result?.sessionId || parsed.id);
423
425
  if (result?.status) out.field("Status", label(result.status));
424
426
  }
427
+ } else if (command === "browsers") {
428
+ if (action === "forget") {
429
+ out.line("Browser registration removed", "accent"); out.field("Name", result.name); out.field("UUID", result.clientId);
430
+ } else {
431
+ const browsers = result.browsers || [];
432
+ out.line(`Browsers · ${browsers.length}`, "accent");
433
+ for (const browser of browsers) { out.line(); out.field("Name", browser.name); out.field("UUID", browser.clientId); out.field("Status", browser.online ? "Connected" : "Offline"); }
434
+ out.line(); out.command('qz create "TASK" --client NAME');
435
+ }
436
+ } else if (command === "connect" && result?.url) {
437
+ out.line(result.opened ? "Browser binding page opened" : "Browser binding URL ready", "accent");
438
+ out.field("URL", result.url);
439
+ out.line("Open this URL in the browser profile you want to connect.");
440
+ out.command("qz browsers");
425
441
  } else if (command === "connect") {
426
442
  out.line(result?.connected ? "Connected to qz" : "Waiting for qz", result?.connected ? "accent" : "waiting");
443
+ const selected = result?.clients?.find(browser => browser.clientId === clientId);
444
+ out.field("Browser", selected?.name);
445
+ out.field("UUID", clientId);
427
446
  out.field("MCP", result?.mcpUrl);
428
- if (result?.connected) out.command('qz create "Summarize the current page."');
447
+ if (result?.connected) out.script(`qz create "Summarize the current page."${client}`);
429
448
  } else if (command === "status") {
430
449
  out.line(result?.connected ? "Connected to qz" : result?.running ? "Service running · browser disconnected" : "Local service stopped", result?.connected ? "accent" : "waiting");
431
450
  out.field("Service", result?.endpoint);
432
451
  if (result?.connected) {
433
452
  out.field("Browsers", result.clientCount ?? result.clients?.length ?? 1);
434
- for (const client of result.clients || []) out.field("Client", client.clientId);
453
+ for (const client of result.clients || []) out.field(client.name || "Client", client.clientId);
435
454
  }
436
- if (result?.updateAvailable) {
455
+ if (result?.updateRequired) {
456
+ out.line("Service upgrade required for browser pairing.", "waiting");
457
+ out.line(result.hint);
458
+ } else if (result?.updateAvailable) {
437
459
  out.line("Service update available. Restart when your current work is finished:", "waiting");
438
460
  out.command("qz stop && qz connect");
439
461
  } else if (!result?.connected) out.command("qz connect");
440
462
  } else if (command === "install") {
441
- out.line(result?.opened ? "Extension download opened" : "Download the qz extension", "accent");
463
+ out.line(result?.opened ? "Chrome Web Store opened" : "Install qz from the Chrome Web Store", "accent");
442
464
  if (!result?.opened) out.line(result?.url);
443
- out.line("Install the extension in Chrome, then connect:"); out.command("qz connect");
465
+ out.line("Click Add to Chrome, then connect:"); out.command("qz connect");
444
466
  } else if (command === "stop") {
445
467
  out.line(result?.stopped ? "Local service stopped" : "Local service is already stopped", "accent");
446
468
  out.command("qz connect");
@@ -470,7 +492,7 @@ export function renderHelp(topic = "", options = {}) {
470
492
  out.command('qz create "Compare these tabs." --title Research --wait');
471
493
  }
472
494
  if (!topic || commandOptions(topic).includes("client")) {
473
- out.line(); out.line("Browser: --client ID (-c)", "muted");
495
+ out.line(); out.line("Browser: --client NAME|UUID (-c)", "muted");
474
496
  }
475
497
  if (!topic || topic === "watch" || commandOptions(topic).includes("wait")) {
476
498
  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 = {}) => {
@@ -88,8 +91,10 @@ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUp
88
91
  : cause.name === "TimeoutError" ? "timeout" : cause.name === "AbortError" ? "interrupted" : "task_wait_failed";
89
92
  const error = Object.assign(new Error(cause.message, { cause }), { code });
90
93
  error.details = { ...cause.details, sessionId, clientId: args.clientId,
91
- recovery: { tool: code === "task_changed" ? "qingzhou_get_session_snapshot" : "qingzhou_watch_task",
92
- arguments: { sessionId, clientId: args.clientId } } };
94
+ recovery: code === "task_deleted"
95
+ ? { tool: "qingzhou_list_sessions", arguments: { clientId: args.clientId } }
96
+ : { tool: code === "task_changed" ? "qingzhou_get_session_snapshot" : "qingzhou_watch_task",
97
+ arguments: { sessionId, clientId: args.clientId } } };
93
98
  throw error;
94
99
  }
95
100
  };
@@ -114,25 +119,11 @@ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUp
114
119
  if (name === "qingzhou_execute_tool") {
115
120
  delete params.wait;
116
121
  params.args ||= {};
117
- return asyncTool ? { ...state.startExtensionJob("execute_tool", params, options), sessionId: args.sessionId, clientId }
122
+ return asyncTool ? { ...state.startExtensionJob("execute_tool", params, { ...options, connectionSignal }), sessionId: args.sessionId, clientId }
118
123
  : state.callExtension("execute_tool", params, options);
119
124
  }
120
125
  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
- }
126
+ let result = await state.callExtension(method, params, options);
136
127
  if (resourceResults.has(name) && result && typeof result.code === "string" && typeof result.error === "string") {
137
128
  throw new BridgeError(result.code, result.error, {
138
129
  ...result.details,