qz-browser-agent 0.1.3 → 0.1.6

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,27 +1,32 @@
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
 
18
22
  async function stdinText({ signal } = {}) {
19
23
  signal?.throwIfAborted();
20
24
  const input = process.stdin;
21
- if (input.readableEnded) return "";
22
25
  return new Promise((resolve, reject) => {
23
26
  const chunks = [];
27
+ let eof;
24
28
  const cleanup = () => {
29
+ clearImmediate(eof);
25
30
  input.removeListener("data", data);
26
31
  input.removeListener("end", end);
27
32
  input.removeListener("error", fail);
@@ -29,43 +34,51 @@ async function stdinText({ signal } = {}) {
29
34
  input.pause();
30
35
  };
31
36
  const data = chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
32
- const end = () => { cleanup(); resolve(Buffer.concat(chunks).toString("utf8")); };
37
+ // npm may close this pipe before its forwarded signal callback runs. Keep
38
+ // cancellation live through that I/O turn instead of reporting empty input.
39
+ const end = () => { eof = setImmediate(() => {
40
+ cleanup();
41
+ if (signal?.aborted) reject(signal.reason);
42
+ else resolve(Buffer.concat(chunks).toString("utf8"));
43
+ }); };
33
44
  const fail = error => { cleanup(); reject(error); };
34
45
  const abort = () => fail(signal.reason);
35
46
  input.on("data", data);
36
47
  input.once("end", end);
37
48
  input.once("error", fail);
38
49
  signal?.addEventListener("abort", abort, { once: true });
39
- input.resume();
50
+ if (input.readableEnded) end();
51
+ else input.resume();
40
52
  });
41
53
  }
42
54
 
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.";
55
+ const UPGRADE_HINT = "Update the installed CLI and browser extension, then retry the command.";
44
56
 
45
57
  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.",
58
+ if (health?.features?.browserPairing !== 1) throw new QzError("UPGRADE_REQUIRED",
59
+ "This qz service is unsupported. Update the CLI and extension, then retry the command.",
48
60
  { hint: UPGRADE_HINT });
49
61
  }
50
62
 
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();
63
+ function connectionRequired() {
64
+ 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
65
  }
66
66
 
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.");
67
+ function imageFilePaths(images, directory, prefix) {
68
+ const files = (Array.isArray(images) ? images : []).filter(image => image && typeof image.data === "string");
69
+ if (!files.length) return null;
70
+ return files.map((image, index) => {
71
+ const extension = image.mimeType === "image/jpeg" ? "jpg" : image.mimeType === "image/webp" ? "webp" : "png";
72
+ const path = join(directory, `${prefix}${index}.${extension}`);
73
+ writeFileSync(path, Buffer.from(image.data, "base64"));
74
+ return path;
75
+ });
76
+ }
77
+
78
+ export function materializeToolImages(result) {
79
+ if (!result || !Array.isArray(result.images) || !result.images.some(image => typeof image?.data === "string")) return result;
80
+ const directory = mkdtempSync(join(tmpdir(), "qz-script-"));
81
+ return { ...result, images: imageFilePaths(result.images, directory, "") };
69
82
  }
70
83
 
71
84
  function recoveryCommand(command, sessionId, clientId, stream = false) {
@@ -96,7 +109,7 @@ async function executeCommand(parsed, dependencies) {
96
109
  return withToolClient(async callTool => {
97
110
  const returned = await dispatchCommand(parsed, { ...dependencies, callTool: dependencies.callTool || callTool });
98
111
  const clientId = parsed.presentation?.clientId;
99
- return clientId && returned.result && typeof returned.result === "object" && !Array.isArray(returned.result)
112
+ return parsed.command !== "session" && clientId && returned.result && typeof returned.result === "object" && !Array.isArray(returned.result)
100
113
  ? { ...returned, result: { ...returned.result, clientId } } : returned;
101
114
  });
102
115
  }
@@ -105,99 +118,96 @@ async function dispatchCommand(parsed, dependencies) {
105
118
  const { command, action, id, options = {}, timeoutSeconds, waitTimeoutSeconds } = parsed;
106
119
  if (command === "help") return { text: renderHelp(parsed.helpTopic) };
107
120
  dependencies.signal?.throwIfAborted();
121
+ if (command === "cleanup") {
122
+ const result = await (dependencies.cleanupDaemons || cleanupDaemons)({ signal: dependencies.signal });
123
+ if (result.skipped) throw new QzError("CLEANUP_INCOMPLETE",
124
+ `Stopped ${result.stopped} qz services and removed ${result.removedFiles} runtime files; ${result.skipped} entries could not be cleaned.`,
125
+ { ...result, hint: "Check the reported entries, then retry qz cleanup." });
126
+ return { result };
127
+ }
108
128
  const open = dependencies.openBrowser || openBrowser;
109
129
  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
- }
130
+ if (command === "install") {
131
+ try { return { result: await open(INSTALL_URL) }; }
132
+ catch (error) {
133
+ if (error?.code === "BROWSER_OPEN_FAILED" && !error.hint && !error.details?.hint) {
134
+ error.hint = "Open the store URL manually, or configure a default browser and retry qz install.";
149
135
  }
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 });
136
+ throw error;
163
137
  }
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 });
138
+ }
139
+ if (command === "connect") {
140
+ const running = await find({ allowIncompatible: true }) || await (dependencies.ensureDaemon || ensureDaemon)(options.port === undefined ? {} : { preferredPort: options.port });
141
+ if (options.port !== undefined && Number(new URL(running.endpoint).port) !== options.port) throw new QzError("PORT_MISMATCH",
142
+ `The local service is already running at ${running.endpoint}.`, { endpoint: running.endpoint, hint: "Use its current address." });
143
+ const url = connectionUri(running, options);
144
+ return options.json ? { result: { url } } : { text: `${url}\n` };
165
145
  }
166
146
  // Validate JSON before connecting; malformed arguments cannot cause browser actions.
147
+ let scriptCode = "";
148
+ if (command === "script") {
149
+ if (options.file && options.file !== "-") {
150
+ try { scriptCode = readFileSync(options.file, "utf8"); }
151
+ catch { throw new QzError("INVALID_ARGUMENTS", `Could not read ${options.file}. Use qz script --session ID [FILE|-] [--args JSON].`); }
152
+ } else {
153
+ scriptCode = await (dependencies.readStdin || stdinText)({ signal: dependencies.signal });
154
+ }
155
+ if (!String(scriptCode).trim()) throw new QzError("INVALID_ARGUMENTS", "The script is empty. Use qz script --session ID [FILE|-] [--args JSON].");
156
+ }
167
157
  let args = {};
168
158
  if (command === "submit" || (command === "settings" && action === "set" && id === undefined)) {
169
159
  const source = options.args === "-" ? await (dependencies.readStdin || stdinText)({ signal: dependencies.signal }) : options.args ?? "{}";
170
160
  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.`);
161
+ catch { throw new QzError("INVALID_ARGUMENTS", "Pass a JSON object, or - to read one from stdin."); }
162
+ if (!args || Array.isArray(args) || typeof args !== "object") throw new QzError("INVALID_ARGUMENTS", `${command === "submit" ? "Answers" : "Settings"} must be a JSON object.`);
173
163
  }
174
164
  let config;
175
165
  if (command === "settings" && action === "set") config = settingsPatch(id, options.value, args);
176
166
  let prompt = options.prompt || "";
177
167
  if (((command === "tasks" && action === "create") || command === "send") && prompt === "-") {
178
168
  prompt = await (dependencies.readStdin || stdinText)({ signal: dependencies.signal });
179
- if (!prompt.trim()) throw new QzError("invalid_arguments", "The prompt read from stdin is empty.");
169
+ if (!prompt.trim()) throw new QzError("INVALID_ARGUMENTS", "The prompt read from stdin is empty.");
180
170
  }
181
171
  dependencies.signal?.throwIfAborted();
182
172
  const resource = resourceRequest(parsed, config);
183
- const running = await find();
173
+ const sessionId = command === "script" ? options.session
174
+ : command === "session" && ["show", "cancel"].includes(action) ? id
175
+ : ["send", "watch", "submit", "approve", "reject"].includes(command) || command === "tasks" && ["get", "stop"].includes(action) ? id : options.session || "";
176
+ const prepared = !["status", "browsers", "mcp"].includes(command)
177
+ ? await prepareConnection({ options, timeoutSeconds, script: command === "script", sessionId }, dependencies) : null;
178
+ const running = prepared?.running || (["mcp", "browsers"].includes(command) ? await (dependencies.ensureDaemon || ensureDaemon)() : await find());
184
179
  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
180
  if (!running) throw connectionRequired();
186
- if (command === "mcp") return options.json ? { result: { url: `${running.endpoint}/mcp` } } : { text: `${running.endpoint}/mcp\n` };
181
+ if (command === "mcp") {
182
+ const url = new URL("/mcp", running.endpoint);
183
+ if (options.profile) url.searchParams.set("profile", options.profile);
184
+ return options.json ? { result: { url: url.href } } : { text: `${url.href}\n` };
185
+ }
187
186
  const call = dependencies.callTool;
188
187
  requirePairingSupport(running.health);
189
188
  if (command === "browsers") {
190
189
  if (action === "list") return { result: running.health.status };
191
190
  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.`);
191
+ if (!browser) throw new QzError("CLIENT_NOT_FOUND", `No registered browser named ${id}. Run qz browsers to inspect names and UUIDs.`);
193
192
  return { result: await call(running.endpoint, "qingzhou_forget_browser", { clientId: browser.clientId }, { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal }) };
194
193
  }
195
194
  // Resolve names once. Every subsequent operation carries this profile UUID.
196
- const clientId = selectClient(running.health, options["client-id"]);
195
+ const clientId = prepared.clientId;
197
196
  parsed.presentation = { ...parsed.presentation, clientId };
198
197
  options["client-id"] = clientId;
199
198
  const common = { timeoutSeconds, ...(clientId ? { clientId } : {}) };
200
199
  const requestOptions = { timeoutMs: timeoutSeconds * 1000, signal: dependencies.signal };
200
+ if (command === "script") {
201
+ const budgets = scriptTimeouts({ timeoutOption: options.timeout, timeoutSeconds });
202
+ const request = buildScriptRequest({ code: scriptCode, timeoutSeconds: budgets.scriptSeconds, sessionId: options.session, args: options.args });
203
+ const result = await call(running.endpoint, "qingzhou_execute_tool", { ...request, ...common, timeoutSeconds: budgets.requestSeconds }, {
204
+ ...requestOptions,
205
+ timeoutMs: budgets.requestSeconds * 1000,
206
+ });
207
+ const output = materializeToolImages(result);
208
+ const shown = presentScriptResult(output);
209
+ return { ...shown, ...(output?.images?.length ? { stderr: output.images.map(path => `Image: ${path}\n`).join("") } : {}) };
210
+ }
201
211
  const startedAt = Date.now();
202
212
  const readSnapshot = async (sessionId, seconds, signal) => {
203
213
  try {
@@ -206,9 +216,9 @@ async function dispatchCommand(parsed, dependencies) {
206
216
  }, { timeoutMs: seconds * 1000, signal });
207
217
  } catch (error) {
208
218
  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.");
219
+ if (error.name === "TimeoutError" || error.code === -32001 || error.code === "TIMEOUT" || error.code === "BRIDGE_REQUEST_TIMEOUT"
220
+ || (error.code === "TOOL_ERROR" && /BRIDGE_REQUEST_TIMEOUT/.test(error.message))) {
221
+ throw new QzError("REQUEST_TIMEOUT", "Reading the task status timed out. The browser task may still be running.");
212
222
  }
213
223
  throw error;
214
224
  }
@@ -225,10 +235,10 @@ async function dispatchCommand(parsed, dependencies) {
225
235
  getSnapshot: ({ timeoutSeconds: seconds, signal }) => readSnapshot(sessionId, seconds, signal),
226
236
  });
227
237
  } 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"
238
+ const code = error.name === "TimeoutError" ? "TIMEOUT" : error.name === "AbortError" ? "INTERRUPTED" : error.code || "TASK_WAIT_FAILED";
239
+ if (code === "TASK_DELETED") throw new QzError(code, error.message, { ...error.details, ...missingTaskDetails(sessionId, clientId) });
240
+ const changed = code === "TASK_CHANGED";
241
+ throw new QzError(code, code === "TIMEOUT"
232
242
  ? "Stopped watching after the configured --timeout. The browser task may still be running." : error.message,
233
243
  { ...error.details, sessionId, ...(clientId ? { clientId } : {}), hint: changed ? "Inspect the task conversation before sending another message:" : "Continue watching the same task:",
234
244
  command: recoveryCommand(changed ? "show" : "watch", sessionId, clientId, !changed) });
@@ -241,7 +251,7 @@ async function dispatchCommand(parsed, dependencies) {
241
251
  }, requestOptions);
242
252
  if (options.wait && result?.accepted === true) {
243
253
  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.", {
254
+ throw new QzError("INVALID_RESPONSE", "The message was accepted without a tracking ID. Inspect the task before sending it again.", {
245
255
  sessionId: id, hint: "Continue watching the same task:", command: recoveryCommand("watch", id, clientId, true),
246
256
  });
247
257
  }
@@ -257,13 +267,13 @@ async function dispatchCommand(parsed, dependencies) {
257
267
  if (!waitId || command === "approve") {
258
268
  input = await call(running.endpoint, "qingzhou_get_user_input", { ...common, sessionId: id }, requestOptions);
259
269
  if (!waitId) {
260
- if (!input?.waiting || !input?.waitId) throw new QzError("not_waiting", "This task has no pending input.", { sessionId: id });
270
+ if (!input?.waiting || !input?.waitId) throw new QzError("NOT_WAITING", "This task has no pending input.", { sessionId: id });
261
271
  waitId = input.waitId;
262
272
  }
263
273
  }
264
274
  if (command === "approve" && input?.waiting && input.waitId === waitId && input.kind === "location_consent") {
265
275
  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 });
276
+ throw new QzError("INVALID_RESPONSE", "The location notice has no valid consent version. Run qz show to review the current request.", { sessionId: id });
267
277
  }
268
278
  consentVersion = input.consentVersion;
269
279
  await dependencies.onUserInput?.(input);
@@ -283,6 +293,13 @@ async function dispatchCommand(parsed, dependencies) {
283
293
  }
284
294
  return { result };
285
295
  }
296
+ if (command === "session" && action === "create") {
297
+ const created = await call(running.endpoint, "qingzhou_create_drive_session", { ...common, ...resource.args }, requestOptions);
298
+ const sessionId = String(created?.sessionId || "").trim();
299
+ if (!sessionId) throw new QzError("INVALID_RESPONSE", "The drive session was created without an ID. Run qz session create again.");
300
+ if (options.json) return { result: created };
301
+ return { text: `sessionId ${sessionId}\n` };
302
+ }
286
303
  let name, parameters;
287
304
  if (resource) {
288
305
  name = resource.name; parameters = { ...common, ...resource.args };
@@ -294,35 +311,32 @@ async function dispatchCommand(parsed, dependencies) {
294
311
  }
295
312
  let result = await call(running.endpoint, name, parameters, requestOptions);
296
313
  if (command === "tasks" && action === "get" && result?.session === null) {
297
- throw new QzError("task_not_found", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
314
+ throw new QzError("TASK_NOT_FOUND", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
298
315
  }
299
316
  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.");
317
+ const sessionId = result?.session?.sessionId || result?.sessionId || result?.session?.id;
318
+ if (!sessionId) throw new QzError("INVALID_RESPONSE", "The task was created without an ID. Run qz tasks to find it before retrying.");
302
319
  await dependencies.onTaskCreated?.(result);
303
320
  result = { ...result, snapshot: await observe(sessionId) };
304
321
  }
305
322
  return { result };
306
323
  }
307
324
 
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." });
325
+ export function errorPayload(error, { script = false } = {}) {
326
+ const interrupted = error.name === "AbortError" || error.code === "INTERRUPTED";
327
+ const timeout = error.name === "TimeoutError" || error.code === -32001 || error.code === "TIMEOUT";
328
+ const code = String(interrupted ? "INTERRUPTED" : timeout ? "TIMEOUT" : error.code || "CONNECTION_ERROR").toUpperCase();
329
+ const { hint: detailHint, ...details } = error.details || {};
330
+ const payload = { code, message: interrupted ? script
331
+ ? "Script request interrupted. Its session and tabs are preserved; check the page state before continuing."
332
+ : "Stopped waiting. A started browser task may still be running."
333
+ : timeout && error.code !== "TIMEOUT" ? "The request timed out. A started browser task may still be running." : error.message,
334
+ ...(error.hint || detailHint ? { hint: error.hint || detailHint } : {}) };
335
+ if (["NOT_CONNECTED", "CONNECTION_ERROR", "CONNECTION_UNAVAILABLE", "CONNECTION_PENDING", "CONNECTION_CHANGED", "BROWSER_OPEN_FAILED", "BRIDGE_NOT_CONNECTED", "BRIDGE_CLOSED"].includes(code)) {
336
+ Object.assign(details, { guide: GUIDE_URL, install: INSTALL_URL });
324
337
  }
325
- return { ok: false, error: payload };
338
+ if (Object.keys(details).length) payload.details = details;
339
+ return payload;
326
340
  }
327
341
 
328
342
  export async function main(argv = process.argv.slice(2), dependencies = {}) {
@@ -331,15 +345,20 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
331
345
  const env = dependencies.env || process.env;
332
346
  // Preserve explicit machine output even when argument validation itself fails.
333
347
  let mode = outputMode({ json: argv.includes("--json"), plain: argv.includes("--plain"), "no-color": argv.includes("--no-color") }, stdout, env);
334
- let live;
348
+ let live, parsed;
335
349
  try {
336
- const parsed = parseCommand(argv);
350
+ parsed = parseCommand(argv);
337
351
  mode = outputMode(parsed.options, stdout, env);
338
352
  if (parsed.command === "help") {
339
353
  const help = renderHelp(parsed.helpTopic, mode);
340
354
  stdout.write(parsed.options.json ? `${JSON.stringify({ ok: true, result: { help } })}\n` : help);
341
355
  return 0;
342
356
  }
357
+ if (parsed.command === "skill") {
358
+ const guide = driveGuide();
359
+ stdout.write(parsed.options.json ? `${JSON.stringify({ ok: true, result: { guide } })}\n` : guide);
360
+ return 0;
361
+ }
343
362
  if (parsed.options.wait) live = createLiveOutput(parsed, { stdout, stderr, ...mode });
344
363
  const result = await executeCommand(parsed, { ...dependencies,
345
364
  onTaskCreated: async task => { live?.created(task); await dependencies.onTaskCreated?.(task); },
@@ -350,17 +369,21 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
350
369
  },
351
370
  });
352
371
  live?.finish();
372
+ if (result.stderr) stderr.write(result.stderr);
353
373
  stdout.write(result.text ?? (mode.json ? `${JSON.stringify({ ok: true, result: result.result })}\n`
354
374
  : renderResult({ ...parsed, presentation: { ...parsed.presentation, streamed: live?.streamed } }, result.result, mode)));
355
375
  const status = result.result?.snapshot?.status || result.result?.snapshot?.runtime?.status;
376
+ if (Number.isInteger(result.exitCode) && result.exitCode > 0) return result.exitCode;
356
377
  if (result.result?.accepted === false && !result.result?.requiresUserAction) return 1;
357
378
  return ["error", "failed", "aborted", "cancelled", "canceled", "interrupted"].includes(status) ? 1 : 0;
358
379
  } catch (error) {
359
380
  live?.finish();
360
- const payload = errorPayload(error);
361
- if (mode.json) stdout.write(`${JSON.stringify(payload)}\n`);
381
+ const scriptCommand = (parsed?.command ?? commandNameFromArgs(argv)) === "script";
382
+ const payload = errorPayload(error, { script: scriptCommand });
383
+ if (scriptCommand) stdout.write(presentScriptFailure(payload).text);
384
+ else if (mode.json) stdout.write(`${JSON.stringify(payload)}\n`);
362
385
  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;
386
+ return payload.code === "INVALID_ARGUMENTS" ? 2 : !scriptCommand && payload.code === "INTERRUPTED" ? 130 : 1;
364
387
  }
365
388
  }
366
389
 
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
  };