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/README.md +150 -36
- package/package.json +1 -1
- package/src/args.mjs +18 -2
- package/src/browser-registry.mjs +101 -0
- package/src/catalog.json +29 -31
- package/src/cli.mjs +62 -15
- package/src/client.mjs +1 -1
- 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/terminal.mjs +35 -15
- package/src/tools.mjs +16 -27
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/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.
|
|
124
|
-
if (cancel) out.
|
|
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.
|
|
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.
|
|
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.
|
|
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?.
|
|
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 ? "
|
|
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("
|
|
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
|
|
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 === "
|
|
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,
|