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/catalog.json CHANGED
@@ -4,20 +4,18 @@
4
4
  "description": "查看本地 bridge 和当前在线的轻舟扩展客户端。",
5
5
  "inputSchema": {
6
6
  "properties": {
7
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
7
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
8
8
  },
9
9
  "title": "qingzhou_bridge_statusArguments",
10
10
  "type": "object"
11
11
  }
12
12
  },
13
13
  {
14
- "name": "qingzhou_bridge_enable_snippet",
15
- "description": "生成在轻舟扩展 DevTools Console 中启用 bridge 的 chrome.storage snippet。",
14
+ "name": "qingzhou_forget_browser",
15
+ "description": "Forget an offline browser registration, freeing its name for explicit pairing. Read qingzhou_bridge_status first and disconnect online browsers in their extension settings.",
16
16
  "inputSchema": {
17
- "properties": {
18
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
19
- },
20
- "title": "qingzhou_bridge_enable_snippetArguments",
17
+ "properties": {"clientId": {"type": "string", "minLength": 1, "description": "Registered browser name or UUID."}},
18
+ "required": ["clientId"],
21
19
  "type": "object"
22
20
  }
23
21
  },
@@ -29,7 +27,7 @@
29
27
  "method": {"title": "Method", "type": "string"},
30
28
  "params": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "default": null, "title": "Params"},
31
29
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
32
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
30
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
33
31
  },
34
32
  "required": ["method"],
35
33
  "title": "qingzhou_callArguments",
@@ -42,7 +40,7 @@
42
40
  "inputSchema": {
43
41
  "properties": {
44
42
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
45
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
43
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
46
44
  },
47
45
  "title": "qingzhou_list_capabilitiesArguments",
48
46
  "type": "object"
@@ -56,7 +54,7 @@
56
54
  "limit": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Limit"},
57
55
  "excludeSessionId": {"default": "", "title": "Excludesessionid", "type": "string"},
58
56
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
59
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
57
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
60
58
  },
61
59
  "title": "qingzhou_list_sessionsArguments",
62
60
  "type": "object"
@@ -74,7 +72,7 @@
74
72
  "firstMessage": {"default": "", "title": "Firstmessage", "type": "string"},
75
73
  "parentSessionId": {"default": "", "title": "Parentsessionid", "type": "string"},
76
74
  "timeoutSeconds": {"anyOf": [{"type": "number", "exclusiveMinimum": 0, "maximum": 86400}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
77
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
75
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
78
76
  },
79
77
  "title": "qingzhou_create_sessionArguments",
80
78
  "type": "object"
@@ -95,7 +93,7 @@
95
93
  "waitForStart": {"default": true, "title": "Waitforstart", "type": "boolean"},
96
94
  "waitForStartMs": {"default": 2000, "title": "Waitforstartms", "type": "number"},
97
95
  "timeoutSeconds": {"anyOf": [{"type": "number", "exclusiveMinimum": 0, "maximum": 86400}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
98
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
96
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
99
97
  },
100
98
  "required": ["text"],
101
99
  "title": "qingzhou_send_messageArguments",
@@ -112,7 +110,7 @@
112
110
  "images": {"anyOf": [{"items": {}, "type": "array"}, {"type": "null"}], "default": null, "title": "Images"},
113
111
  "clientMessageId": {"default": "", "title": "Clientmessageid", "type": "string"},
114
112
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
115
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
113
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
116
114
  },
117
115
  "required": ["text"],
118
116
  "title": "qingzhou_follow_upArguments",
@@ -127,7 +125,7 @@
127
125
  "sessionId": {"title": "Sessionid", "type": "string"},
128
126
  "cascade": {"default": true, "title": "Cascade", "type": "boolean"},
129
127
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
130
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
128
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
131
129
  },
132
130
  "required": ["sessionId"],
133
131
  "title": "qingzhou_abort_sessionArguments",
@@ -176,7 +174,7 @@
176
174
  "waitId": {"title": "Waitid", "type": "string", "minLength": 1},
177
175
  "payload": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "default": null, "title": "Payload"},
178
176
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
179
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
177
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
180
178
  },
181
179
  "required": ["sessionId", "waitId"],
182
180
  "title": "qingzhou_continue_taskArguments",
@@ -193,7 +191,7 @@
193
191
  "waitId": {"title": "Waitid", "type": "string", "minLength": 1},
194
192
  "reason": {"default": "submitted", "title": "Reason", "type": "string"},
195
193
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
196
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
194
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
197
195
  },
198
196
  "required": ["sessionId", "answers", "waitId"],
199
197
  "title": "qingzhou_submit_wait_formArguments",
@@ -208,7 +206,7 @@
208
206
  "sessionId": {"title": "Sessionid", "type": "string"},
209
207
  "waitId": {"title": "Waitid", "type": "string", "minLength": 1},
210
208
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
211
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
209
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
212
210
  },
213
211
  "required": ["sessionId", "waitId"],
214
212
  "title": "qingzhou_cancel_waitArguments",
@@ -222,7 +220,7 @@
222
220
  "properties": {
223
221
  "sessionId": {"default": "default", "title": "Sessionid", "type": "string"},
224
222
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
225
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
223
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
226
224
  },
227
225
  "title": "qingzhou_get_session_stateArguments",
228
226
  "type": "object"
@@ -242,7 +240,7 @@
242
240
  "traceLimit": {"default": 40, "title": "Tracelimit", "type": "number"},
243
241
  "includeTools": {"default": false, "title": "Includetools", "type": "boolean"},
244
242
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
245
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
243
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
246
244
  },
247
245
  "title": "qingzhou_get_session_snapshotArguments",
248
246
  "type": "object"
@@ -258,7 +256,7 @@
258
256
  "tail": {"default": true, "title": "Tail", "type": "boolean"},
259
257
  "num": {"default": 50, "title": "Num", "type": "number"},
260
258
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
261
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
259
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
262
260
  },
263
261
  "title": "qingzhou_get_messagesArguments",
264
262
  "type": "object"
@@ -272,7 +270,7 @@
272
270
  "sessionId": {"title": "Sessionid", "type": "string"},
273
271
  "cascade": {"default": true, "title": "Cascade", "type": "boolean"},
274
272
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
275
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
273
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
276
274
  },
277
275
  "required": ["sessionId"],
278
276
  "title": "qingzhou_stop_sessionArguments",
@@ -286,7 +284,7 @@
286
284
  "properties": {
287
285
  "sessionId": {"default": "default", "title": "Sessionid", "type": "string"},
288
286
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
289
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
287
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
290
288
  },
291
289
  "title": "qingzhou_list_toolsArguments",
292
290
  "type": "object"
@@ -300,7 +298,7 @@
300
298
  "scope": {"default": "all", "title": "Scope", "type": "string"},
301
299
  "query": {"default": "", "title": "Query", "type": "string"},
302
300
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
303
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
301
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
304
302
  },
305
303
  "title": "qingzhou_list_skillsArguments",
306
304
  "type": "object"
@@ -315,7 +313,7 @@
315
313
  "includeContent": {"default": true, "title": "Includecontent", "type": "boolean"},
316
314
  "includeResources": {"default": false, "title": "Includeresources", "type": "boolean"},
317
315
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
318
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
316
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
319
317
  },
320
318
  "required": ["name"],
321
319
  "title": "qingzhou_get_skillArguments",
@@ -332,7 +330,7 @@
332
330
  "start": {"default": 0, "title": "Start", "type": "number"},
333
331
  "maxChars": {"default": 300000, "title": "Maxchars", "type": "number"},
334
332
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
335
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
333
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
336
334
  },
337
335
  "required": ["name", "path"],
338
336
  "title": "qingzhou_get_skill_resourceArguments",
@@ -350,7 +348,7 @@
350
348
  "runId": {"default": "", "title": "Runid", "type": "string"},
351
349
  "wait": {"default": true, "title": "Wait", "type": "boolean"},
352
350
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
353
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
351
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
354
352
  },
355
353
  "required": ["name"],
356
354
  "title": "qingzhou_execute_toolArguments",
@@ -405,7 +403,7 @@
405
403
  "inputSchema": {
406
404
  "properties": {
407
405
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
408
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
406
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
409
407
  },
410
408
  "title": "qingzhou_get_configArguments",
411
409
  "type": "object"
@@ -418,7 +416,7 @@
418
416
  "properties": {
419
417
  "config": {"additionalProperties": true, "title": "Config", "type": "object"},
420
418
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
421
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
419
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
422
420
  },
423
421
  "required": ["config"],
424
422
  "title": "qingzhou_save_configArguments",
@@ -432,7 +430,7 @@
432
430
  "properties": {
433
431
  "sessionId": {"default": "default", "title": "Sessionid", "type": "string"},
434
432
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
435
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
433
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
436
434
  },
437
435
  "title": "qingzhou_list_pluginsArguments",
438
436
  "type": "object"
@@ -446,7 +444,7 @@
446
444
  "limit": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Limit"},
447
445
  "runLimit": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Runlimit"},
448
446
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
449
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
447
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
450
448
  },
451
449
  "title": "qingzhou_list_automationsArguments",
452
450
  "type": "object"
@@ -458,7 +456,7 @@
458
456
  "inputSchema": {
459
457
  "properties": {
460
458
  "timeoutSeconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "default": null, "title": "Timeoutseconds"},
461
- "clientId": {"default": "", "title": "Clientid", "type": "string"}
459
+ "clientId": {"default": "", "title": "Clientid", "type": "string", "description": "Browser name or UUID. Required when multiple browsers are online. Offline or unknown selections never fall back to another browser."}
462
460
  },
463
461
  "title": "qingzhou_get_traceArguments",
464
462
  "type": "object"
package/src/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { ensureDaemon, findDaemon, stopDaemon } from "./daemon.mjs";
7
7
  import { openBrowser } from "./open-browser.mjs";
8
8
  import { parseCommand } from "./args.mjs";
9
9
  import { renderError, renderHelp, renderResult, renderInputNotice, sanitizeTerminalText } from "./terminal.mjs";
10
- import { waitForTask } from "./task-stream.mjs";
10
+ import { TASK_NOT_FOUND_MESSAGE, waitForTask } from "./task-stream.mjs";
11
11
  import { createLiveOutput, outputMode } from "./output.mjs";
12
12
  import { resourceRequest, settingsPatch } from "./cli-resources.mjs";
13
13
  import { PACKAGE_NAME, PACKAGE_VERSION } from "./package-info.mjs";
@@ -40,8 +40,28 @@ async function stdinText({ signal } = {}) {
40
40
  });
41
41
  }
42
42
 
43
- function connected(health, clientId = "") {
44
- return clientId ? health.status?.clients?.some(client => client.clientId === clientId) : Boolean(health.status?.connected);
43
+ const UPGRADE_HINT = "Use the latest CLI. After current tasks finish, run npx --yes qz-browser-agent@latest stop (disconnects all browsers), update and reload the qz extension, then run npx --yes qz-browser-agent@latest connect.";
44
+
45
+ function requirePairingSupport(health) {
46
+ if (health?.features?.browserPairing !== 1) throw new QzError("upgrade_required",
47
+ "This qz service is unsupported. Upgrade the CLI and extension, then run qz stop and qz connect.",
48
+ { hint: UPGRADE_HINT });
49
+ }
50
+
51
+ function selectClient(health, selector = "", { allowEmpty = false } = {}) {
52
+ const clients = health.status?.clients || [];
53
+ if (selector) {
54
+ const entry = (health.status?.browsers || []).find(client => client.clientId === selector || client.name === selector);
55
+ const selected = clients.find(client => client.clientId === (entry?.clientId || selector));
56
+ if (selected) return selected.clientId;
57
+ throw new QzError(entry ? "client_offline" : "client_not_found", `Browser ${selector} is ${entry ? "offline" : "not registered"}.`,
58
+ { hint: "Run qz browsers to inspect names and UUIDs. Reconnect that profile from its extension settings." });
59
+ }
60
+ if (clients.length > 1) throw new QzError("selection_required", "Multiple browsers are connected. Select one with --client NAME|UUID.",
61
+ { clients: clients.map(({ clientId, name }) => ({ clientId, name })), hint: clients.map(client => `${client.name || client.clientId}: ${client.clientId}`).join("\n") });
62
+ if (clients.length === 1) return clients[0].clientId;
63
+ if (allowEmpty) return "";
64
+ throw connectionRequired();
45
65
  }
46
66
 
47
67
  function connectionRequired() {
@@ -53,11 +73,18 @@ function recoveryCommand(command, sessionId, clientId, stream = false) {
53
73
  if (clientId && sanitizeTerminalText(clientId) !== String(clientId)) clientId = "CLIENT_ID";
54
74
  const client = clientId ? ` --client${String(clientId).startsWith("-") ? "=" : " "}${shellArgument(clientId)}` : "";
55
75
  const options = `${client}${stream ? " --stream" : ""}`;
76
+ if (!sessionId) return `qz ${command}${options}`;
56
77
  return String(sessionId).startsWith("-")
57
78
  ? `qz ${command}${options} ${optionTerminator()} ${shellArgument(sessionId)}`
58
79
  : `qz ${command} ${shellArgument(sessionId)}${options}`;
59
80
  }
60
81
 
82
+ function missingTaskDetails(sessionId, clientId) {
83
+ return { sessionId, ...(clientId ? { clientId } : {}),
84
+ hint: "Check the task ID and selected browser. Run qz browsers to inspect browser names and UUIDs. List tasks in the selected browser:",
85
+ command: recoveryCommand("tasks", "", clientId) };
86
+ }
87
+
61
88
  export async function runCli(argv, dependencies = {}) {
62
89
  return executeCommand(parseCommand(argv), dependencies);
63
90
  }
@@ -66,7 +93,12 @@ async function executeCommand(parsed, dependencies) {
66
93
  if (parsed.command === "version") return parsed.options.json
67
94
  ? { result: { name: PACKAGE_NAME, version: PACKAGE_VERSION } }
68
95
  : { text: `qz ${PACKAGE_VERSION}\n` };
69
- return withToolClient(callTool => dispatchCommand(parsed, { ...dependencies, callTool: dependencies.callTool || callTool }));
96
+ return withToolClient(async callTool => {
97
+ const returned = await dispatchCommand(parsed, { ...dependencies, callTool: dependencies.callTool || callTool });
98
+ const clientId = parsed.presentation?.clientId;
99
+ return clientId && returned.result && typeof returned.result === "object" && !Array.isArray(returned.result)
100
+ ? { ...returned, result: { ...returned.result, clientId } } : returned;
101
+ });
70
102
  }
71
103
 
72
104
  async function dispatchCommand(parsed, dependencies) {
@@ -79,11 +111,24 @@ async function dispatchCommand(parsed, dependencies) {
79
111
  if (command === "install") return { result: await open(INSTALL_URL) };
80
112
  if (command === "stop") return { result: await (dependencies.stopDaemon || stopDaemon)() };
81
113
  if (command === "connect") {
82
- const running = await (dependencies.ensureDaemon || ensureDaemon)();
83
- const connectUrl = `${running.endpoint}/qz-connect`;
114
+ const running = await (dependencies.ensureDaemon || ensureDaemon)(options.port === undefined ? {} : { preferredPort: options.port });
115
+ requirePairingSupport(running.health);
116
+ if (options.port !== undefined && Number(new URL(running.endpoint).port) !== options.port) throw new QzError("port_mismatch",
117
+ `The local service is already running at ${running.endpoint}.`, { endpoint: running.endpoint,
118
+ hint: "Use its current address, or run qz stop before connecting on a different port." });
119
+ const bindingUrl = new URL("/qz-connect", running.endpoint);
120
+ if (options.name !== undefined) bindingUrl.searchParams.set("name", options.name);
121
+ const connectUrl = bindingUrl.href;
122
+ if (options["no-open"] || options.name !== undefined) {
123
+ dependencies.signal?.throwIfAborted();
124
+ const opened = options["no-open"] ? false : Boolean((await open(connectUrl))?.opened);
125
+ return { result: { endpoint: running.endpoint, url: connectUrl, mcpUrl: `${running.endpoint}/mcp`, opened } };
126
+ }
84
127
  const deadline = Date.now() + timeoutSeconds * 1000;
128
+ let selected = selectClient(running.health, options["client-id"], { allowEmpty: true });
85
129
  const probe = async health => {
86
- const clients = (health?.status?.clients || []).filter(client => !options["client-id"] || client.clientId === options["client-id"]);
130
+ const chosen = selected || selectClient(health, "", { allowEmpty: true });
131
+ const clients = (health?.status?.clients || []).filter(client => client.clientId === chosen);
87
132
  for (const client of clients) {
88
133
  dependencies.signal?.throwIfAborted();
89
134
  const timeoutMs = Math.min(2000, deadline - Date.now());
@@ -94,17 +139,19 @@ async function dispatchCommand(parsed, dependencies) {
94
139
  await dependencies.callTool(running.endpoint, "qingzhou_call", {
95
140
  method: "ping", clientId: client.clientId, timeoutSeconds: timeoutMs / 1000,
96
141
  }, { timeoutMs, signal: dependencies.signal ? AbortSignal.any([dependencies.signal, timeout]) : timeout });
142
+ selected = client.clientId;
97
143
  return true;
98
144
  } catch (error) {
99
145
  dependencies.signal?.throwIfAborted();
100
146
  if (error.code === "connection_error" || (!timeout.aborted
101
- && !["BRIDGE_NOT_CONNECTED", "BRIDGE_REQUEST_TIMEOUT", -32001].includes(error.code))) throw error;
147
+ && !["BRIDGE_NOT_CONNECTED", "BRIDGE_CLIENT_OFFLINE", "BRIDGE_CLIENT_DISCONNECTED", "BRIDGE_REQUEST_TIMEOUT", -32001].includes(error.code))) throw error;
102
148
  }
103
149
  }
104
150
  return false;
105
151
  };
106
- const result = health => ({ result: { connected: true, mcpUrl: `${running.endpoint}/mcp`, clients: health.status.clients } });
152
+ const result = health => ({ result: { connected: true, mcpUrl: `${running.endpoint}/mcp`, clients: health.status.clients, clientId: selected } });
107
153
  if (await probe(running.health)) return result(running.health);
154
+ if (options["client-id"]) throw new QzError("client_offline", "The selected browser is not responding. Reconnect it from its extension settings.");
108
155
  dependencies.signal?.throwIfAborted();
109
156
  await open(connectUrl);
110
157
  while (Date.now() < deadline) {
@@ -114,7 +161,7 @@ async function dispatchCommand(parsed, dependencies) {
114
161
  if (health && await probe(health)) return result(health);
115
162
  await delay(200, undefined, { signal: dependencies.signal });
116
163
  }
117
- throw new QzError("connection_pending", "qz is waiting for Chrome. Open the connection URL in the Chrome profile with qz installed and accept its data notice. Then run qz connect again.", { url: connectUrl });
164
+ throw new QzError("connection_pending", "qz is waiting for Chrome. Open the connection URL in the Chrome profile with qz installed and confirm the connection in qz. Then run qz connect again.", { url: connectUrl });
118
165
  }
119
166
  // Validate JSON before connecting; malformed arguments cannot cause browser actions.
120
167
  let args = {};
@@ -134,14 +181,21 @@ async function dispatchCommand(parsed, dependencies) {
134
181
  dependencies.signal?.throwIfAborted();
135
182
  const resource = resourceRequest(parsed, config);
136
183
  const running = await find();
137
- if (command === "status") return { result: running ? { running: true, endpoint: running.endpoint, updateAvailable: running.updateAvailable === true, ...running.health.status } : { running: false, connected: false } };
184
+ if (command === "status") return { result: running ? { running: true, endpoint: running.endpoint, updateAvailable: running.updateAvailable === true, ...(running.health.features?.browserPairing === 1 ? {} : { updateRequired: true, hint: UPGRADE_HINT }), ...running.health.status } : { running: false, connected: false } };
138
185
  if (!running) throw connectionRequired();
139
186
  if (command === "mcp") return options.json ? { result: { url: `${running.endpoint}/mcp` } } : { text: `${running.endpoint}/mcp\n` };
140
187
  const call = dependencies.callTool;
141
- if (!connected(running.health, options["client-id"])) throw connectionRequired();
142
- const clients = running.health.status?.clients || [];
143
- // Keep a waited task in its creating browser even if other profiles connect or disconnect.
144
- const clientId = options["client-id"] || ((options.wait || ["submit", "approve", "reject"].includes(command)) && clients.length === 1 ? clients[0].clientId : "");
188
+ requirePairingSupport(running.health);
189
+ if (command === "browsers") {
190
+ if (action === "list") return { result: running.health.status };
191
+ const browser = running.health.status?.browsers?.find(browser => browser.clientId === id || browser.name === id);
192
+ if (!browser) throw new QzError("client_not_found", `No registered browser named ${id}. Run qz browsers to inspect names and UUIDs.`);
193
+ return { result: await call(running.endpoint, "qingzhou_forget_browser", { clientId: browser.clientId }, { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal }) };
194
+ }
195
+ // Resolve names once. Every subsequent operation carries this profile UUID.
196
+ const clientId = selectClient(running.health, options["client-id"]);
197
+ parsed.presentation = { ...parsed.presentation, clientId };
198
+ options["client-id"] = clientId;
145
199
  const common = { timeoutSeconds, ...(clientId ? { clientId } : {}) };
146
200
  const requestOptions = { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal };
147
201
  const startedAt = Date.now();
@@ -172,6 +226,7 @@ async function dispatchCommand(parsed, dependencies) {
172
226
  });
173
227
  } catch (error) {
174
228
  const code = error.name === "TimeoutError" ? "timeout" : error.name === "AbortError" ? "interrupted" : error.code || "task_wait_failed";
229
+ if (code === "task_deleted") throw new QzError(code, error.message, { ...error.details, ...missingTaskDetails(sessionId, clientId) });
175
230
  const changed = code === "task_changed";
176
231
  throw new QzError(code, code === "timeout"
177
232
  ? "Stopped watching after the configured --timeout. The browser task may still be running." : error.message,
@@ -236,6 +291,9 @@ async function dispatchCommand(parsed, dependencies) {
236
291
  ...(action === "list" && options.limit !== undefined ? { limit: Number(options.limit) } : {}) };
237
292
  }
238
293
  let result = await call(running.endpoint, name, parameters, requestOptions);
294
+ if (command === "tasks" && action === "get" && result?.session === null) {
295
+ throw new QzError("task_not_found", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
296
+ }
239
297
  if (command === "tasks" && action === "create" && options.wait) {
240
298
  const sessionId = result?.session?.id || result?.sessionId;
241
299
  if (!sessionId) throw new QzError("invalid_response", "The task was created without an ID. Run qz tasks to find it before retrying.");
@@ -291,7 +349,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
291
349
  });
292
350
  live?.finish();
293
351
  stdout.write(result.text ?? (mode.json ? `${JSON.stringify({ ok: true, result: result.result })}\n`
294
- : renderResult({ ...parsed, presentation: { streamed: live?.streamed } }, result.result, mode)));
352
+ : renderResult({ ...parsed, presentation: { ...parsed.presentation, streamed: live?.streamed } }, result.result, mode)));
295
353
  const status = result.result?.snapshot?.status || result.result?.snapshot?.runtime?.status;
296
354
  if (result.result?.accepted === false && !result.result?.requiresUserAction) return 1;
297
355
  return ["error", "failed", "aborted", "cancelled", "canceled", "interrupted"].includes(status) ? 1 : 0;
@@ -304,10 +362,30 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
304
362
  }
305
363
  }
306
364
 
365
+ /** Keep handlers installed while npm forwards duplicate signals and MCP closes. */
366
+ export async function runMain(argv = process.argv.slice(2), dependencies = {}) {
367
+ const controller = new AbortController();
368
+ let interruptedExitCode;
369
+ const interrupt = code => {
370
+ if (controller.signal.aborted) return;
371
+ interruptedExitCode = code;
372
+ controller.abort();
373
+ };
374
+ const onSigint = () => interrupt(130);
375
+ const onSigterm = () => interrupt(143);
376
+ process.on("SIGINT", onSigint);
377
+ process.on("SIGTERM", onSigterm);
378
+ try {
379
+ const code = await main(argv, { ...dependencies, signal: controller.signal });
380
+ return interruptedExitCode ?? code;
381
+ } finally {
382
+ process.removeListener("SIGINT", onSigint);
383
+ process.removeListener("SIGTERM", onSigterm);
384
+ }
385
+ }
386
+
307
387
  let isEntry = false;
308
388
  try { isEntry = realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch {}
309
389
  if (isEntry) {
310
- const controller = new AbortController();
311
- process.once("SIGINT", () => controller.abort());
312
- process.exitCode = await main(process.argv.slice(2), { signal: controller.signal });
390
+ process.exitCode = await runMain();
313
391
  }
package/src/client.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  import { request } from "node:http";
2
- import { Readable } from "node:stream";
3
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
3
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
4
  import { PACKAGE_VERSION } from "./package-info.mjs";
6
5
 
7
6
  export const GUIDE_URL = "https://github.com/alibaba/qz-browser-agent/blob/main/services/mcp-bridge/README.md";
8
- export const INSTALL_URL = "https://github.com/alibaba/qz-browser-agent/releases/latest";
7
+ export { INSTALL_URL } from "./package-info.mjs";
9
8
  export const SERVICE_NAME = "qz-browser-agent";
10
9
  export const PROTOCOL_VERSION = 1;
11
10
 
@@ -27,6 +26,19 @@ export function localUrl(value) {
27
26
  return url;
28
27
  }
29
28
 
29
+ function responseBody(response) {
30
+ const iterator = response[Symbol.asyncIterator]();
31
+ return {
32
+ [Symbol.asyncIterator]() { return this; },
33
+ next: () => iterator.next(),
34
+ return() {
35
+ // An iterator return waits behind a pending read. Destroy the HTTP stream first.
36
+ response.destroy();
37
+ return iterator.return();
38
+ },
39
+ };
40
+ }
41
+
30
42
  // node:http does not inherit proxy environment settings. Every MCP request stays on loopback.
31
43
  export async function localFetch(input, init) {
32
44
  const prepared = new Request(input, init);
@@ -42,7 +54,7 @@ export async function localFetch(input, init) {
42
54
  return;
43
55
  }
44
56
  try {
45
- resolve(new Response([204, 304].includes(res.statusCode) ? null : Readable.toWeb(res), {
57
+ resolve(new Response([204, 304].includes(res.statusCode) ? null : responseBody(res), {
46
58
  status: res.statusCode, headers: new Headers(Object.entries(res.headers)
47
59
  .flatMap(([key, value]) => Array.isArray(value) ? value.map(item => [key, item]) : value === undefined ? [] : [[key, value]])),
48
60
  }));
package/src/commands.mjs CHANGED
@@ -8,8 +8,8 @@ const route = (tool, args = () => ({}), options = []) => ({ tool, args, options
8
8
  // CLI syntax, documentation, and direct MCP routes have one declaration per command.
9
9
  // Task execution and form responses retain their explicit handlers in cli.mjs.
10
10
  export const COMMANDS = {
11
- connect: { options: request, summary: "Connect the browser",
12
- help: ["qz connect [--timeout SECONDS]", "Start the local service and open its connection page.", "Use the Chrome profile with qz installed and accept its data notice.", "qz status"],
11
+ connect: { options: [...request, "port", "no-open", "name"], summary: "Connect the browser",
12
+ help: ["qz connect [--client NAME|UUID] [--port PORT] [--timeout SECONDS]", "Reuse a connected browser or open the connection page for first pairing.", "qz connect --no-open [--name NAME] --json", "Return a binding URL immediately. Open it in the target browser profile.", "--name assigns or changes that profile's name. Without --no-open, its binding page opens immediately.", "Use --port for a custom local service port. An existing service remains running.", "qz browsers"],
13
13
  },
14
14
  create: { options: [...waiting, "title", "parent"], summary: "Start a task",
15
15
  help: ['qz create "PROMPT" [--title TITLE] [--wait | --stream]', "Start a browser task. The title is optional.",
@@ -101,17 +101,20 @@ export const COMMANDS = {
101
101
  run: route("qingzhou_run_automation", ({ id }) => ({ automationId: id })),
102
102
  },
103
103
  },
104
+ browsers: { options: [], summary: "List or forget browser registrations",
105
+ help: ["qz browsers [list]", "List registered browser names, UUIDs, and connection status.", "qz browsers forget NAME|UUID", "Forget an offline registration to reuse its name. Disconnect online browsers in their extension settings first."],
106
+ },
104
107
  status: { options: [], summary: "Check the connection",
105
108
  help: ["qz status", "Show the local service and browser connection status."],
106
109
  },
107
110
  mcp: { options: [], summary: "Print the MCP URL",
108
111
  help: ["qz mcp [--json]", "Print the running service's MCP URL for an external agent.", "Run qz connect first."],
109
112
  },
110
- install: { options: [], summary: "Download the extension",
111
- help: ["qz install", "Open the latest extension download in your browser.", "Install it in Chrome, then run qz connect."],
113
+ install: { options: [], summary: "Install the Chrome extension",
114
+ help: ["qz install", "Open qz in the Chrome Web Store.", "Click Add to Chrome, then run qz connect."],
112
115
  },
113
116
  stop: { options: [], summary: "Stop the local service",
114
- help: ["qz stop", "Stop the local service and disconnect the browser.", "Use qz cancel TASK_ID to stop a task.", "qz connect"],
117
+ help: ["qz stop", "Stop the local service and disconnect all browsers.", "Use qz cancel TASK_ID to stop a task.", "qz connect"],
115
118
  },
116
119
  help: { options: [] },
117
120
  };
package/src/daemon.mjs CHANGED
@@ -83,6 +83,7 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
83
83
  await mkdir(candidate, { mode: 0o700 });
84
84
  const deadline = Date.now() + timeoutMs;
85
85
  let acquired = false;
86
+ let missingTargetPermissionError;
86
87
  try {
87
88
  await writeFile(join(candidate, ownerFile), JSON.stringify(owner), { mode: 0o600 });
88
89
  while (true) {
@@ -92,12 +93,25 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
92
93
  acquired = true;
93
94
  break;
94
95
  } catch (error) {
95
- const existing = await stat(paths.lock).catch(() => null);
96
+ const existing = await stat(paths.lock).catch(error => {
97
+ if (error.code === "ENOENT") return null;
98
+ throw error;
99
+ });
96
100
  if (!existing) {
101
+ if (process.platform === "win32" && error.code === "EPERM") {
102
+ // Windows reports directory collisions as EPERM. The owner may have
103
+ // released the lock before stat; try the same atomic publication once.
104
+ // A persistent denial remains EPERM rather than becoming a busy lock.
105
+ if (missingTargetPermissionError || Date.now() >= deadline) throw missingTargetPermissionError || error;
106
+ missingTargetPermissionError = error;
107
+ await delay(50);
108
+ continue;
109
+ }
97
110
  if (!["EEXIST", "ENOTEMPTY", "ENOTDIR"].includes(error.code)) throw error;
98
111
  if (Date.now() >= deadline) throw new QzError("connection_busy", "Another qz command is starting the local service. Try qz status shortly.");
99
112
  continue;
100
113
  }
114
+ missingTargetPermissionError = undefined;
101
115
  if (existing.isDirectory()) {
102
116
  for (const name of await readdir(paths.lock).catch(() => [])) {
103
117
  if (!/^\d+-[\da-f-]+\.json$/.test(name)) continue;
@@ -236,11 +250,12 @@ export async function stopDaemon(options = {}) {
236
250
 
237
251
  export async function startManagedBridge({ stateFile, scopeId, buildId = "", instanceId = randomUUID(), port = 18765, startBridge } = {}) {
238
252
  const start = startBridge || (await import("./server.mjs")).startBridge;
253
+ const registryPath = stateFile.replace(/\.json$/, "") + ".browsers.json";
239
254
  let running;
240
- try { running = await start({ port, scopeId, instanceId, buildId }); }
255
+ try { running = await start({ port, scopeId, instanceId, buildId, registryPath }); }
241
256
  catch (error) {
242
257
  if (error.code !== "EADDRINUSE" || !port) throw error;
243
- running = await start({ port: 0, scopeId, instanceId, buildId });
258
+ running = await start({ port: 0, scopeId, instanceId, buildId, registryPath });
244
259
  }
245
260
  const record = { service: SERVICE_NAME, protocolVersion: PROTOCOL_VERSION, endpoint: running.endpoint,
246
261
  processId: process.pid, scopeId, instanceId, buildId };
@@ -4,3 +4,4 @@ const metadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.
4
4
 
5
5
  export const PACKAGE_NAME = metadata.name;
6
6
  export const PACKAGE_VERSION = metadata.version;
7
+ export const INSTALL_URL = "https://chromewebstore.google.com/detail/dknbdgkgoinlgjpfebapkklbmjjgdblm";