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/README.md +91 -318
- package/package.json +2 -2
- package/src/args.mjs +18 -2
- package/src/bin.mjs +5 -7
- package/src/browser-registry.mjs +101 -0
- package/src/catalog.json +29 -31
- package/src/cli.mjs +97 -19
- package/src/client.mjs +15 -3
- package/src/commands.mjs +8 -5
- package/src/daemon.mjs +18 -3
- package/src/package-info.mjs +1 -0
- package/src/server.mjs +18 -8
- package/src/state.mjs +136 -32
- package/src/task-stream.mjs +5 -3
- package/src/terminal.mjs +38 -16
- package/src/tools.mjs +20 -29
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
|
|
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="${
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
return
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
108
|
-
if (
|
|
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})` : ""}.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
185
|
-
job.result = await this.callExtension(method, params, { ...options, signal:
|
|
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/task-stream.mjs
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
124
|
-
if (cancel) out.
|
|
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.
|
|
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.
|
|
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.
|
|
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?.
|
|
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 ? "
|
|
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("
|
|
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
|
|
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 === "
|
|
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:
|
|
92
|
-
arguments: {
|
|
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,
|