qz-browser-agent 0.1.3 → 0.1.5

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/cli.mjs CHANGED
@@ -1,17 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
- import { setTimeout as delay } from "node:timers/promises";
5
- import { GUIDE_URL, INSTALL_URL, QzError, withToolClient, readHealth } from "./client.mjs";
6
- import { ensureDaemon, findDaemon, stopDaemon } from "./daemon.mjs";
6
+ import { GUIDE_URL, INSTALL_URL, QzError, withToolClient } from "./client.mjs";
7
+ import { cleanupDaemons, ensureDaemon, findDaemon } from "./daemon.mjs";
7
8
  import { openBrowser } from "./open-browser.mjs";
8
- import { parseCommand } from "./args.mjs";
9
+ import { connectionUri, prepareConnection } from "./connection.mjs";
10
+ import { commandNameFromArgs, parseCommand } from "./args.mjs";
9
11
  import { renderError, renderHelp, renderResult, renderInputNotice, sanitizeTerminalText } from "./terminal.mjs";
10
12
  import { TASK_NOT_FOUND_MESSAGE, waitForTask } from "./task-stream.mjs";
11
13
  import { createLiveOutput, outputMode } from "./output.mjs";
12
14
  import { resourceRequest, settingsPatch } from "./cli-resources.mjs";
13
15
  import { PACKAGE_NAME, PACKAGE_VERSION } from "./package-info.mjs";
14
16
  import { optionTerminator, shellArgument } from "./shell.mjs";
17
+ import { driveGuide } from "./drive-guide.mjs";
18
+ import { buildScriptRequest, presentScriptFailure, presentScriptResult, scriptTimeouts } from "./script-call.mjs";
15
19
 
16
20
  export const HELP = renderHelp();
17
21
 
@@ -40,32 +44,33 @@ async function stdinText({ signal } = {}) {
40
44
  });
41
45
  }
42
46
 
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.";
47
+ const UPGRADE_HINT = "Update the installed CLI and browser extension, then retry the command.";
44
48
 
45
49
  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.",
50
+ if (health?.features?.browserPairing !== 1) throw new QzError("UPGRADE_REQUIRED",
51
+ "This qz service is unsupported. Update the CLI and extension, then retry the command.",
48
52
  { hint: UPGRADE_HINT });
49
53
  }
50
54
 
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();
55
+ function connectionRequired() {
56
+ return new QzError("NOT_CONNECTED", "Run qz connect uri and open its URL in the browser profile with qz installed. Session and script commands prepare their connection automatically.");
65
57
  }
66
58
 
67
- function connectionRequired() {
68
- return new QzError("not_connected", "Run qz connect to start the local service and connect Chrome. If qz is not installed, run qz install.");
59
+ function imageFilePaths(images, directory, prefix) {
60
+ const files = (Array.isArray(images) ? images : []).filter(image => image && typeof image.data === "string");
61
+ if (!files.length) return null;
62
+ return files.map((image, index) => {
63
+ const extension = image.mimeType === "image/jpeg" ? "jpg" : image.mimeType === "image/webp" ? "webp" : "png";
64
+ const path = join(directory, `${prefix}${index}.${extension}`);
65
+ writeFileSync(path, Buffer.from(image.data, "base64"));
66
+ return path;
67
+ });
68
+ }
69
+
70
+ export function materializeToolImages(result) {
71
+ if (!result || !Array.isArray(result.images) || !result.images.some(image => typeof image?.data === "string")) return result;
72
+ const directory = mkdtempSync(join(tmpdir(), "qz-script-"));
73
+ return { ...result, images: imageFilePaths(result.images, directory, "") };
69
74
  }
70
75
 
71
76
  function recoveryCommand(command, sessionId, clientId, stream = false) {
@@ -96,7 +101,7 @@ async function executeCommand(parsed, dependencies) {
96
101
  return withToolClient(async callTool => {
97
102
  const returned = await dispatchCommand(parsed, { ...dependencies, callTool: dependencies.callTool || callTool });
98
103
  const clientId = parsed.presentation?.clientId;
99
- return clientId && returned.result && typeof returned.result === "object" && !Array.isArray(returned.result)
104
+ return parsed.command !== "session" && clientId && returned.result && typeof returned.result === "object" && !Array.isArray(returned.result)
100
105
  ? { ...returned, result: { ...returned.result, clientId } } : returned;
101
106
  });
102
107
  }
@@ -105,99 +110,96 @@ async function dispatchCommand(parsed, dependencies) {
105
110
  const { command, action, id, options = {}, timeoutSeconds, waitTimeoutSeconds } = parsed;
106
111
  if (command === "help") return { text: renderHelp(parsed.helpTopic) };
107
112
  dependencies.signal?.throwIfAborted();
113
+ if (command === "cleanup") {
114
+ const result = await (dependencies.cleanupDaemons || cleanupDaemons)({ signal: dependencies.signal });
115
+ if (result.skipped) throw new QzError("CLEANUP_INCOMPLETE",
116
+ `Stopped ${result.stopped} qz services and removed ${result.removedFiles} runtime files; ${result.skipped} entries could not be cleaned.`,
117
+ { ...result, hint: "Check the reported entries, then retry qz cleanup." });
118
+ return { result };
119
+ }
108
120
  const open = dependencies.openBrowser || openBrowser;
109
121
  const find = dependencies.findDaemon || findDaemon;
110
- const healthReader = dependencies.readHealth || readHealth;
111
- if (command === "install") return { result: await open(INSTALL_URL) };
112
- if (command === "stop") return { result: await (dependencies.stopDaemon || stopDaemon)() };
113
- if (command === "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
- }
127
- const deadline = Date.now() + timeoutSeconds * 1000;
128
- let selected = selectClient(running.health, options["client-id"], { allowEmpty: true });
129
- const probe = async health => {
130
- const chosen = selected || selectClient(health, "", { allowEmpty: true });
131
- const clients = (health?.status?.clients || []).filter(client => client.clientId === chosen);
132
- for (const client of clients) {
133
- dependencies.signal?.throwIfAborted();
134
- const timeoutMs = Math.min(2000, deadline - Date.now());
135
- if (timeoutMs <= 0) return false;
136
- const timeout = AbortSignal.timeout(timeoutMs);
137
- try {
138
- // A lease can outlive a stopped poller. Confirm a real extension round trip.
139
- await dependencies.callTool(running.endpoint, "qingzhou_call", {
140
- method: "ping", clientId: client.clientId, timeoutSeconds: timeoutMs / 1000,
141
- }, { timeoutMs, signal: dependencies.signal ? AbortSignal.any([dependencies.signal, timeout]) : timeout });
142
- selected = client.clientId;
143
- return true;
144
- } catch (error) {
145
- dependencies.signal?.throwIfAborted();
146
- if (error.code === "connection_error" || (!timeout.aborted
147
- && !["BRIDGE_NOT_CONNECTED", "BRIDGE_CLIENT_OFFLINE", "BRIDGE_CLIENT_DISCONNECTED", "BRIDGE_REQUEST_TIMEOUT", -32001].includes(error.code))) throw error;
148
- }
122
+ if (command === "install") {
123
+ try { return { result: await open(INSTALL_URL) }; }
124
+ catch (error) {
125
+ if (error?.code === "BROWSER_OPEN_FAILED" && !error.hint && !error.details?.hint) {
126
+ error.hint = "Open the store URL manually, or configure a default browser and retry qz install.";
149
127
  }
150
- return false;
151
- };
152
- const result = health => ({ result: { connected: true, mcpUrl: `${running.endpoint}/mcp`, clients: health.status.clients, clientId: selected } });
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.");
155
- dependencies.signal?.throwIfAborted();
156
- await open(connectUrl);
157
- while (Date.now() < deadline) {
158
- dependencies.signal?.throwIfAborted();
159
- const health = await healthReader(running.endpoint).catch(() => null);
160
- if (health && health.instanceId !== running.instanceId) throw new QzError("connection_changed", "The local service changed. Run qz connect again.", { url: connectUrl });
161
- if (health && await probe(health)) return result(health);
162
- await delay(200, undefined, { signal: dependencies.signal });
128
+ throw error;
163
129
  }
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 });
130
+ }
131
+ if (command === "connect") {
132
+ const running = await find({ allowIncompatible: true }) || await (dependencies.ensureDaemon || ensureDaemon)(options.port === undefined ? {} : { preferredPort: options.port });
133
+ if (options.port !== undefined && Number(new URL(running.endpoint).port) !== options.port) throw new QzError("PORT_MISMATCH",
134
+ `The local service is already running at ${running.endpoint}.`, { endpoint: running.endpoint, hint: "Use its current address." });
135
+ const url = connectionUri(running, options);
136
+ return options.json ? { result: { url } } : { text: `${url}\n` };
165
137
  }
166
138
  // Validate JSON before connecting; malformed arguments cannot cause browser actions.
139
+ let scriptCode = "";
140
+ if (command === "script") {
141
+ if (options.file && options.file !== "-") {
142
+ try { scriptCode = readFileSync(options.file, "utf8"); }
143
+ catch { throw new QzError("INVALID_ARGUMENTS", `Could not read ${options.file}. Use qz script --session ID [FILE|-] [--args JSON].`); }
144
+ } else {
145
+ scriptCode = await (dependencies.readStdin || stdinText)({ signal: dependencies.signal });
146
+ }
147
+ if (!String(scriptCode).trim()) throw new QzError("INVALID_ARGUMENTS", "The script is empty. Use qz script --session ID [FILE|-] [--args JSON].");
148
+ }
167
149
  let args = {};
168
150
  if (command === "submit" || (command === "settings" && action === "set" && id === undefined)) {
169
151
  const source = options.args === "-" ? await (dependencies.readStdin || stdinText)({ signal: dependencies.signal }) : options.args ?? "{}";
170
152
  try { args = JSON.parse(source); }
171
- catch { throw new QzError("invalid_arguments", "Pass a JSON object, or - to read one from stdin."); }
172
- if (!args || Array.isArray(args) || typeof args !== "object") throw new QzError("invalid_arguments", `${command === "submit" ? "Answers" : "Settings"} must be a JSON object.`);
153
+ catch { throw new QzError("INVALID_ARGUMENTS", "Pass a JSON object, or - to read one from stdin."); }
154
+ if (!args || Array.isArray(args) || typeof args !== "object") throw new QzError("INVALID_ARGUMENTS", `${command === "submit" ? "Answers" : "Settings"} must be a JSON object.`);
173
155
  }
174
156
  let config;
175
157
  if (command === "settings" && action === "set") config = settingsPatch(id, options.value, args);
176
158
  let prompt = options.prompt || "";
177
159
  if (((command === "tasks" && action === "create") || command === "send") && prompt === "-") {
178
160
  prompt = await (dependencies.readStdin || stdinText)({ signal: dependencies.signal });
179
- if (!prompt.trim()) throw new QzError("invalid_arguments", "The prompt read from stdin is empty.");
161
+ if (!prompt.trim()) throw new QzError("INVALID_ARGUMENTS", "The prompt read from stdin is empty.");
180
162
  }
181
163
  dependencies.signal?.throwIfAborted();
182
164
  const resource = resourceRequest(parsed, config);
183
- const running = await find();
165
+ const sessionId = command === "script" ? options.session
166
+ : command === "session" && ["show", "cancel"].includes(action) ? id
167
+ : ["send", "watch", "submit", "approve", "reject"].includes(command) || command === "tasks" && ["get", "stop"].includes(action) ? id : options.session || "";
168
+ const prepared = !["status", "browsers", "mcp"].includes(command)
169
+ ? await prepareConnection({ options, timeoutSeconds, script: command === "script", sessionId }, dependencies) : null;
170
+ const running = prepared?.running || (["mcp", "browsers"].includes(command) ? await (dependencies.ensureDaemon || ensureDaemon)() : await find());
184
171
  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 } };
185
172
  if (!running) throw connectionRequired();
186
- if (command === "mcp") return options.json ? { result: { url: `${running.endpoint}/mcp` } } : { text: `${running.endpoint}/mcp\n` };
173
+ if (command === "mcp") {
174
+ const url = new URL("/mcp", running.endpoint);
175
+ if (options.profile) url.searchParams.set("profile", options.profile);
176
+ return options.json ? { result: { url: url.href } } : { text: `${url.href}\n` };
177
+ }
187
178
  const call = dependencies.callTool;
188
179
  requirePairingSupport(running.health);
189
180
  if (command === "browsers") {
190
181
  if (action === "list") return { result: running.health.status };
191
182
  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.`);
183
+ if (!browser) throw new QzError("CLIENT_NOT_FOUND", `No registered browser named ${id}. Run qz browsers to inspect names and UUIDs.`);
193
184
  return { result: await call(running.endpoint, "qingzhou_forget_browser", { clientId: browser.clientId }, { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal }) };
194
185
  }
195
186
  // Resolve names once. Every subsequent operation carries this profile UUID.
196
- const clientId = selectClient(running.health, options["client-id"]);
187
+ const clientId = prepared.clientId;
197
188
  parsed.presentation = { ...parsed.presentation, clientId };
198
189
  options["client-id"] = clientId;
199
190
  const common = { timeoutSeconds, ...(clientId ? { clientId } : {}) };
200
191
  const requestOptions = { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal };
192
+ if (command === "script") {
193
+ const budgets = scriptTimeouts({ timeoutOption: options.timeout, timeoutSeconds });
194
+ const request = buildScriptRequest({ code: scriptCode, timeoutSeconds: budgets.scriptSeconds, sessionId: options.session, args: options.args });
195
+ const result = await call(running.endpoint, "qingzhou_execute_tool", { ...request, ...common, timeoutSeconds: budgets.requestSeconds }, {
196
+ ...requestOptions,
197
+ timeoutMs: budgets.requestSeconds * 1000,
198
+ });
199
+ const output = materializeToolImages(result);
200
+ const shown = presentScriptResult(output);
201
+ return { ...shown, ...(output?.images?.length ? { stderr: output.images.map(path => `Image: ${path}\n`).join("") } : {}) };
202
+ }
201
203
  const startedAt = Date.now();
202
204
  const readSnapshot = async (sessionId, seconds, signal) => {
203
205
  try {
@@ -206,9 +208,9 @@ async function dispatchCommand(parsed, dependencies) {
206
208
  }, { timeoutMs: seconds * 1000, signal });
207
209
  } catch (error) {
208
210
  signal?.throwIfAborted();
209
- if (error.name === "TimeoutError" || error.code === -32001 || error.code === "timeout" || error.code === "BRIDGE_REQUEST_TIMEOUT"
210
- || (error.code === "tool_error" && /BRIDGE_REQUEST_TIMEOUT/.test(error.message))) {
211
- throw new QzError("request_timeout", "Reading the task status timed out. The browser task may still be running.");
211
+ if (error.name === "TimeoutError" || error.code === -32001 || error.code === "TIMEOUT" || error.code === "BRIDGE_REQUEST_TIMEOUT"
212
+ || (error.code === "TOOL_ERROR" && /BRIDGE_REQUEST_TIMEOUT/.test(error.message))) {
213
+ throw new QzError("REQUEST_TIMEOUT", "Reading the task status timed out. The browser task may still be running.");
212
214
  }
213
215
  throw error;
214
216
  }
@@ -225,10 +227,10 @@ async function dispatchCommand(parsed, dependencies) {
225
227
  getSnapshot: ({ timeoutSeconds: seconds, signal }) => readSnapshot(sessionId, seconds, signal),
226
228
  });
227
229
  } catch (error) {
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) });
230
- const changed = code === "task_changed";
231
- throw new QzError(code, code === "timeout"
230
+ const code = error.name === "TimeoutError" ? "TIMEOUT" : error.name === "AbortError" ? "INTERRUPTED" : error.code || "TASK_WAIT_FAILED";
231
+ if (code === "TASK_DELETED") throw new QzError(code, error.message, { ...error.details, ...missingTaskDetails(sessionId, clientId) });
232
+ const changed = code === "TASK_CHANGED";
233
+ throw new QzError(code, code === "TIMEOUT"
232
234
  ? "Stopped watching after the configured --timeout. The browser task may still be running." : error.message,
233
235
  { ...error.details, sessionId, ...(clientId ? { clientId } : {}), hint: changed ? "Inspect the task conversation before sending another message:" : "Continue watching the same task:",
234
236
  command: recoveryCommand(changed ? "show" : "watch", sessionId, clientId, !changed) });
@@ -241,7 +243,7 @@ async function dispatchCommand(parsed, dependencies) {
241
243
  }, requestOptions);
242
244
  if (options.wait && result?.accepted === true) {
243
245
  if (typeof result.clientMessageId !== "string" || !result.clientMessageId.trim()) {
244
- throw new QzError("invalid_response", "The message was accepted without a tracking ID. Inspect the task before sending it again.", {
246
+ throw new QzError("INVALID_RESPONSE", "The message was accepted without a tracking ID. Inspect the task before sending it again.", {
245
247
  sessionId: id, hint: "Continue watching the same task:", command: recoveryCommand("watch", id, clientId, true),
246
248
  });
247
249
  }
@@ -257,13 +259,13 @@ async function dispatchCommand(parsed, dependencies) {
257
259
  if (!waitId || command === "approve") {
258
260
  input = await call(running.endpoint, "qingzhou_get_user_input", { ...common, sessionId: id }, requestOptions);
259
261
  if (!waitId) {
260
- if (!input?.waiting || !input?.waitId) throw new QzError("not_waiting", "This task has no pending input.", { sessionId: id });
262
+ if (!input?.waiting || !input?.waitId) throw new QzError("NOT_WAITING", "This task has no pending input.", { sessionId: id });
261
263
  waitId = input.waitId;
262
264
  }
263
265
  }
264
266
  if (command === "approve" && input?.waiting && input.waitId === waitId && input.kind === "location_consent") {
265
267
  if (!Number.isInteger(input.consentVersion) || input.consentVersion < 1) {
266
- throw new QzError("invalid_response", "The location notice has no valid consent version. Run qz show to review the current request.", { sessionId: id });
268
+ throw new QzError("INVALID_RESPONSE", "The location notice has no valid consent version. Run qz show to review the current request.", { sessionId: id });
267
269
  }
268
270
  consentVersion = input.consentVersion;
269
271
  await dependencies.onUserInput?.(input);
@@ -283,6 +285,13 @@ async function dispatchCommand(parsed, dependencies) {
283
285
  }
284
286
  return { result };
285
287
  }
288
+ if (command === "session" && action === "create") {
289
+ const created = await call(running.endpoint, "qingzhou_create_drive_session", { ...common, ...resource.args }, requestOptions);
290
+ const sessionId = String(created?.sessionId || "").trim();
291
+ if (!sessionId) throw new QzError("INVALID_RESPONSE", "The drive session was created without an ID. Run qz session create again.");
292
+ if (options.json) return { result: created };
293
+ return { text: `sessionId ${sessionId}\n` };
294
+ }
286
295
  let name, parameters;
287
296
  if (resource) {
288
297
  name = resource.name; parameters = { ...common, ...resource.args };
@@ -294,35 +303,32 @@ async function dispatchCommand(parsed, dependencies) {
294
303
  }
295
304
  let result = await call(running.endpoint, name, parameters, requestOptions);
296
305
  if (command === "tasks" && action === "get" && result?.session === null) {
297
- throw new QzError("task_not_found", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
306
+ throw new QzError("TASK_NOT_FOUND", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
298
307
  }
299
308
  if (command === "tasks" && action === "create" && options.wait) {
300
- const sessionId = result?.session?.id || result?.sessionId;
301
- if (!sessionId) throw new QzError("invalid_response", "The task was created without an ID. Run qz tasks to find it before retrying.");
309
+ const sessionId = result?.session?.sessionId || result?.sessionId || result?.session?.id;
310
+ if (!sessionId) throw new QzError("INVALID_RESPONSE", "The task was created without an ID. Run qz tasks to find it before retrying.");
302
311
  await dependencies.onTaskCreated?.(result);
303
312
  result = { ...result, snapshot: await observe(sessionId) };
304
313
  }
305
314
  return { result };
306
315
  }
307
316
 
308
- export function errorPayload(error) {
309
- if (error.code === "invalid_arguments") return { ok: false, error: {
310
- code: error.code, message: error.message,
311
- hint: "With npm, use npm run qz -- <command> [options]. Run qz --help for usage.",
312
- } };
313
- const interrupted = error.name === "AbortError" || error.code === "interrupted";
314
- const timeout = error.name === "TimeoutError" || error.code === -32001 || error.code === "timeout";
315
- const code = interrupted ? "interrupted" : timeout ? "timeout" : error.code || "connection_error";
316
- const payload = { code, message: interrupted ? "Stopped waiting. A started browser task may still be running."
317
- : timeout && error.code !== "timeout" ? "The request timed out. A started browser task may still be running." : error.message, ...error.details };
318
- if (!payload.hint && (timeout || interrupted)) payload.hint = "Run qz tasks to inspect the task before repeating the command.";
319
- if (!payload.hint && code === "BRIDGE_REQUEST_TIMEOUT") payload.hint = "The browser operation may still be running. Check the task before repeating the command.";
320
- if (!payload.hint && code === "BRIDGE_CLIENT_REQUIRED") payload.hint = "Run qz status to list browser IDs, then repeat with --client ID.";
321
- if (["not_connected", "connection_error", "connection_unavailable", "connection_pending", "connection_changed", "browser_open_failed", "BRIDGE_NOT_CONNECTED", "BRIDGE_CLOSED"].includes(code)) {
322
- Object.assign(payload, { guide: GUIDE_URL, install: INSTALL_URL,
323
- hint: payload.hint || "Run qz install if the extension is missing, then qz connect. See the GitHub guide for setup." });
317
+ export function errorPayload(error, { script = false } = {}) {
318
+ const interrupted = error.name === "AbortError" || error.code === "INTERRUPTED";
319
+ const timeout = error.name === "TimeoutError" || error.code === -32001 || error.code === "TIMEOUT";
320
+ const code = String(interrupted ? "INTERRUPTED" : timeout ? "TIMEOUT" : error.code || "CONNECTION_ERROR").toUpperCase();
321
+ const { hint: detailHint, ...details } = error.details || {};
322
+ const payload = { code, message: interrupted ? script
323
+ ? "Script request interrupted. Its session and tabs are preserved; check the page state before continuing."
324
+ : "Stopped waiting. A started browser task may still be running."
325
+ : timeout && error.code !== "TIMEOUT" ? "The request timed out. A started browser task may still be running." : error.message,
326
+ ...(error.hint || detailHint ? { hint: error.hint || detailHint } : {}) };
327
+ if (["NOT_CONNECTED", "CONNECTION_ERROR", "CONNECTION_UNAVAILABLE", "CONNECTION_PENDING", "CONNECTION_CHANGED", "BROWSER_OPEN_FAILED", "BRIDGE_NOT_CONNECTED", "BRIDGE_CLOSED"].includes(code)) {
328
+ Object.assign(details, { guide: GUIDE_URL, install: INSTALL_URL });
324
329
  }
325
- return { ok: false, error: payload };
330
+ if (Object.keys(details).length) payload.details = details;
331
+ return payload;
326
332
  }
327
333
 
328
334
  export async function main(argv = process.argv.slice(2), dependencies = {}) {
@@ -331,15 +337,20 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
331
337
  const env = dependencies.env || process.env;
332
338
  // Preserve explicit machine output even when argument validation itself fails.
333
339
  let mode = outputMode({ json: argv.includes("--json"), plain: argv.includes("--plain"), "no-color": argv.includes("--no-color") }, stdout, env);
334
- let live;
340
+ let live, parsed;
335
341
  try {
336
- const parsed = parseCommand(argv);
342
+ parsed = parseCommand(argv);
337
343
  mode = outputMode(parsed.options, stdout, env);
338
344
  if (parsed.command === "help") {
339
345
  const help = renderHelp(parsed.helpTopic, mode);
340
346
  stdout.write(parsed.options.json ? `${JSON.stringify({ ok: true, result: { help } })}\n` : help);
341
347
  return 0;
342
348
  }
349
+ if (parsed.command === "skill") {
350
+ const guide = driveGuide();
351
+ stdout.write(parsed.options.json ? `${JSON.stringify({ ok: true, result: { guide } })}\n` : guide);
352
+ return 0;
353
+ }
343
354
  if (parsed.options.wait) live = createLiveOutput(parsed, { stdout, stderr, ...mode });
344
355
  const result = await executeCommand(parsed, { ...dependencies,
345
356
  onTaskCreated: async task => { live?.created(task); await dependencies.onTaskCreated?.(task); },
@@ -350,17 +361,21 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
350
361
  },
351
362
  });
352
363
  live?.finish();
364
+ if (result.stderr) stderr.write(result.stderr);
353
365
  stdout.write(result.text ?? (mode.json ? `${JSON.stringify({ ok: true, result: result.result })}\n`
354
366
  : renderResult({ ...parsed, presentation: { ...parsed.presentation, streamed: live?.streamed } }, result.result, mode)));
355
367
  const status = result.result?.snapshot?.status || result.result?.snapshot?.runtime?.status;
368
+ if (Number.isInteger(result.exitCode) && result.exitCode > 0) return result.exitCode;
356
369
  if (result.result?.accepted === false && !result.result?.requiresUserAction) return 1;
357
370
  return ["error", "failed", "aborted", "cancelled", "canceled", "interrupted"].includes(status) ? 1 : 0;
358
371
  } catch (error) {
359
372
  live?.finish();
360
- const payload = errorPayload(error);
361
- if (mode.json) stdout.write(`${JSON.stringify(payload)}\n`);
373
+ const scriptCommand = (parsed?.command ?? commandNameFromArgs(argv)) === "script";
374
+ const payload = errorPayload(error, { script: scriptCommand });
375
+ if (scriptCommand) stdout.write(presentScriptFailure(payload).text);
376
+ else if (mode.json) stdout.write(`${JSON.stringify(payload)}\n`);
362
377
  else stderr.write(renderError(payload, { ...mode, color: mode.color && Boolean(stderr.isTTY) }));
363
- return payload.error.code === "invalid_arguments" ? 2 : payload.error.code === "interrupted" ? 130 : 1;
378
+ return payload.code === "INVALID_ARGUMENTS" ? 2 : !scriptCommand && payload.code === "INTERRUPTED" ? 130 : 1;
364
379
  }
365
380
  }
366
381
 
package/src/client.mjs CHANGED
@@ -11,17 +11,17 @@ export const PROTOCOL_VERSION = 1;
11
11
  export class QzError extends Error {
12
12
  constructor(code, message, details = {}) {
13
13
  super(message);
14
- this.code = code;
14
+ this.code = String(code || "CONNECTION_ERROR").toUpperCase();
15
15
  this.details = details;
16
16
  }
17
17
  }
18
18
 
19
19
  export function localUrl(value) {
20
20
  let url;
21
- try { url = new URL(value); } catch { throw new QzError("invalid_endpoint", "The local service URL is invalid."); }
21
+ try { url = new URL(value); } catch { throw new QzError("INVALID_ENDPOINT", "The local service URL is invalid."); }
22
22
  if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname)
23
23
  || url.username || url.password || url.hash || url.search || url.port === "0") {
24
- throw new QzError("invalid_endpoint", "Use an HTTP local service URL on 127.0.0.1 or localhost.");
24
+ throw new QzError("INVALID_ENDPOINT", "Use an HTTP local service URL on 127.0.0.1 or localhost.");
25
25
  }
26
26
  return url;
27
27
  }
@@ -50,7 +50,7 @@ export async function localFetch(input, init) {
50
50
  headers: { ...Object.fromEntries(prepared.headers), host: url.host } }, res => {
51
51
  if (res.statusCode >= 300 && res.statusCode < 400) {
52
52
  res.resume();
53
- reject(new QzError("redirect_blocked", "The local service returned a redirect."));
53
+ reject(new QzError("REDIRECT_BLOCKED", "The local service returned a redirect."));
54
54
  return;
55
55
  }
56
56
  try {
@@ -65,21 +65,58 @@ export async function localFetch(input, init) {
65
65
  });
66
66
  }
67
67
 
68
- export async function readHealth(endpoint, { timeoutMs = 1000, fetch = localFetch } = {}) {
68
+ export async function readHealth(endpoint, { timeoutMs = 1000, fetch = localFetch, allowIncompatible = false, signal } = {}) {
69
+ signal?.throwIfAborted();
69
70
  const url = localUrl(endpoint);
70
- const response = await fetch(`${url.origin}/health`, { signal: AbortSignal.timeout(timeoutMs) });
71
- if (!response.ok) throw new QzError("connection_unavailable", `The qz connection health check failed (${response.status}).`);
72
- const health = await response.json();
73
- if (health.service !== SERVICE_NAME || health.protocolVersion !== PROTOCOL_VERSION || !health.instanceId) {
74
- throw new QzError("incompatible_service", "The local service is not a compatible qz installation.");
71
+ const timeout = AbortSignal.timeout(timeoutMs);
72
+ try {
73
+ const response = await fetch(`${url.origin}/health`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
74
+ if (!response.ok) throw new QzError("CONNECTION_UNAVAILABLE", `The qz connection health check failed (${response.status}).`);
75
+ const health = await response.json();
76
+ signal?.throwIfAborted();
77
+ if (health.service !== SERVICE_NAME || !health.instanceId || (!allowIncompatible && health.protocolVersion !== PROTOCOL_VERSION)) {
78
+ throw new QzError("INCOMPATIBLE_SERVICE", "The local service is not a compatible qz installation.");
79
+ }
80
+ return health;
81
+ } catch (error) {
82
+ signal?.throwIfAborted();
83
+ throw error;
75
84
  }
76
- return health;
85
+ }
86
+
87
+ function restoreCallImages(value, blocks) {
88
+ const tagged = blocks.map((block, position) => ({
89
+ mimeType: block.mimeType,
90
+ data: block.data,
91
+ id: String(block._meta?.id || ""),
92
+ index: Number.isInteger(block._meta?.index) ? block._meta.index : position,
93
+ }));
94
+ const bytes = image => ({ mimeType: image.mimeType, data: image.data });
95
+ if (!Array.isArray(value.results)) return { ...value, images: tagged.map(bytes) };
96
+ const used = new Set();
97
+ const results = value.results.map(item => {
98
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
99
+ const id = String(item.id || "");
100
+ const owned = tagged.filter(image => image.id && image.id === id);
101
+ for (const image of owned) used.add(image);
102
+ if (!owned.length) return item;
103
+ owned.sort((left, right) => left.index - right.index);
104
+ return { ...item, images: owned.map(bytes) };
105
+ });
106
+ const leftover = tagged.filter(image => !image.id || !used.has(image));
107
+ return { ...value, results, ...(leftover.length ? { images: leftover.map(bytes) } : {}) };
77
108
  }
78
109
 
79
110
  export function resultValue(result) {
80
111
  if (result.structuredContent != null) return result.structuredContent;
81
- if (result.content?.length === 1 && result.content[0].type === "text") {
82
- try { return JSON.parse(result.content[0].text); } catch { return result.content[0].text; }
112
+ const blocks = Array.isArray(result.content) ? result.content : [];
113
+ const texts = blocks.filter(block => block?.type === "text");
114
+ const images = blocks.filter(block => block?.type === "image" && typeof block.data === "string");
115
+ if (texts.length === 1 && images.length + texts.length === blocks.length) {
116
+ let value;
117
+ try { value = JSON.parse(texts[0].text); } catch { value = texts[0].text; }
118
+ if (images.length && value && typeof value === "object" && !Array.isArray(value)) return restoreCallImages(value, images);
119
+ if (!images.length) return value;
83
120
  }
84
121
  return { content: result.content || [] };
85
122
  }
@@ -88,7 +125,7 @@ function initialize(client, transport, timeoutMs, signal) {
88
125
  let timer, aborted;
89
126
  return new Promise((resolve, reject) => {
90
127
  const fail = error => {
91
- const failure = new QzError("connection_error", `Could not initialize the MCP connection: ${error.message}`);
128
+ const failure = new QzError("CONNECTION_ERROR", `Could not initialize the MCP connection: ${error.message}`);
92
129
  if (error.name === "AbortError") failure.name = error.name;
93
130
  reject(failure);
94
131
  };
@@ -105,7 +142,7 @@ export async function withToolClient(operation) {
105
142
  const call = async (target, name, args, { timeoutMs = 180000, signal, fetch = localFetch } = {}) => {
106
143
  signal?.throwIfAborted();
107
144
  const origin = localUrl(target).origin;
108
- if (endpoint && endpoint !== origin) throw new QzError("connection_changed", "The local service changed during the command.");
145
+ if (endpoint && endpoint !== origin) throw new QzError("CONNECTION_CHANGED", "The local service changed during the command.");
109
146
  if (!client) {
110
147
  endpoint = origin;
111
148
  client = new Client({ name: "qz-cli", version: PACKAGE_VERSION });
@@ -117,14 +154,19 @@ export async function withToolClient(operation) {
117
154
  }
118
155
  await connecting;
119
156
  signal?.throwIfAborted();
120
- const result = await client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs, signal });
157
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs, signal }).catch(error => {
158
+ // The MCP SDK wraps caller cancellation as RequestTimeout; retain the caller's reason.
159
+ signal?.throwIfAborted();
160
+ throw error;
161
+ });
121
162
  const value = resultValue(result);
122
163
  if (result.isError) {
123
164
  if (value && typeof value.code === "string" && typeof value.message === "string") {
124
- throw new QzError(value.code, value.message,
125
- value.details && typeof value.details === "object" && !Array.isArray(value.details) ? value.details : {});
165
+ throw Object.assign(new QzError(value.code, value.message,
166
+ value.details && typeof value.details === "object" && !Array.isArray(value.details) ? value.details : {}),
167
+ value.hint ? { hint: value.hint } : {});
126
168
  }
127
- throw new QzError("tool_error", typeof value === "string" ? value : "The browser tool failed.", { result: value });
169
+ throw new QzError("TOOL_ERROR", typeof value === "string" ? value : "The browser tool failed.", { result: value });
128
170
  }
129
171
  return value;
130
172
  };