qz-browser-agent 0.1.2 → 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/README.md +61 -26
- package/package.json +5 -2
- package/src/args.mjs +57 -16
- package/src/bin.mjs +2 -2
- package/src/catalog.json +79 -16
- package/src/cli-resources.mjs +2 -2
- package/src/cli.mjs +140 -123
- package/src/client.mjs +61 -19
- package/src/commands.mjs +52 -15
- package/src/connect-page.mjs +89 -0
- package/src/connection.mjs +145 -0
- package/src/daemon.mjs +180 -31
- package/src/drive-guide.mjs +38 -0
- package/src/drive-timeout.mjs +21 -0
- package/src/open-browser.mjs +40 -11
- package/src/qz-drive-SKILL.md +165 -0
- package/src/qz-logo-day.png +0 -0
- package/src/qz-logo.png +0 -0
- package/src/script-call.mjs +40 -0
- package/src/server.mjs +25 -8
- package/src/state.mjs +81 -29
- package/src/task-stream.mjs +67 -65
- package/src/terminal.mjs +68 -18
- package/src/tools.mjs +152 -32
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 {
|
|
5
|
-
import {
|
|
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 {
|
|
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 = "
|
|
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("
|
|
47
|
-
"This qz service is unsupported.
|
|
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
|
|
52
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
172
|
-
if (!args || Array.isArray(args) || typeof args !== "object") throw new QzError("
|
|
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("
|
|
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
|
|
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")
|
|
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("
|
|
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 =
|
|
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,29 +208,29 @@ 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 === "
|
|
210
|
-
|| (error.code === "
|
|
211
|
-
throw new QzError("
|
|
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
|
}
|
|
215
217
|
};
|
|
216
|
-
const observe = async (sessionId, { runId = "", expectedMessageId = "",
|
|
218
|
+
const observe = async (sessionId, { runId = "", expectedMessageId = "", messageWasQueued = false, ignoreWaitId = "", allowIdle = false } = {}) => {
|
|
217
219
|
try {
|
|
218
220
|
const remaining = waitTimeoutSeconds == null ? null : waitTimeoutSeconds - (Date.now() - startedAt) / 1000;
|
|
219
221
|
if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after the configured --timeout. The browser task may still be running.", "TimeoutError");
|
|
220
222
|
return await waitForTask({
|
|
221
223
|
sessionId, clientId: common.clientId, timeoutSeconds: remaining, signal: dependencies.signal,
|
|
222
224
|
requestTimeoutSeconds: timeoutSeconds,
|
|
223
|
-
expectedRunId: runId, expectedMessageId,
|
|
225
|
+
expectedRunId: runId, expectedMessageId, messageWasQueued, ignoreWaitId, allowIdle,
|
|
224
226
|
onUpdate: dependencies.onTaskUpdate,
|
|
225
227
|
getSnapshot: ({ timeoutSeconds: seconds, signal }) => readSnapshot(sessionId, seconds, signal),
|
|
226
228
|
});
|
|
227
229
|
} catch (error) {
|
|
228
|
-
const code = error.name === "TimeoutError" ? "
|
|
229
|
-
if (code === "
|
|
230
|
-
const changed = code === "
|
|
231
|
-
throw new QzError(code, code === "
|
|
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,11 +243,13 @@ 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("
|
|
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
|
}
|
|
248
|
-
result = { ...result, snapshot: await observe(id, {
|
|
250
|
+
result = { ...result, snapshot: await observe(id, {
|
|
251
|
+
expectedMessageId: result.clientMessageId, messageWasQueued: result.disposition === "queued",
|
|
252
|
+
}) };
|
|
249
253
|
}
|
|
250
254
|
return { result };
|
|
251
255
|
}
|
|
@@ -255,13 +259,13 @@ async function dispatchCommand(parsed, dependencies) {
|
|
|
255
259
|
if (!waitId || command === "approve") {
|
|
256
260
|
input = await call(running.endpoint, "qingzhou_get_user_input", { ...common, sessionId: id }, requestOptions);
|
|
257
261
|
if (!waitId) {
|
|
258
|
-
if (!input?.waiting || !input?.waitId) throw new QzError("
|
|
262
|
+
if (!input?.waiting || !input?.waitId) throw new QzError("NOT_WAITING", "This task has no pending input.", { sessionId: id });
|
|
259
263
|
waitId = input.waitId;
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
if (command === "approve" && input?.waiting && input.waitId === waitId && input.kind === "location_consent") {
|
|
263
267
|
if (!Number.isInteger(input.consentVersion) || input.consentVersion < 1) {
|
|
264
|
-
throw new QzError("
|
|
268
|
+
throw new QzError("INVALID_RESPONSE", "The location notice has no valid consent version. Run qz show to review the current request.", { sessionId: id });
|
|
265
269
|
}
|
|
266
270
|
consentVersion = input.consentVersion;
|
|
267
271
|
await dependencies.onUserInput?.(input);
|
|
@@ -281,6 +285,13 @@ async function dispatchCommand(parsed, dependencies) {
|
|
|
281
285
|
}
|
|
282
286
|
return { result };
|
|
283
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
|
+
}
|
|
284
295
|
let name, parameters;
|
|
285
296
|
if (resource) {
|
|
286
297
|
name = resource.name; parameters = { ...common, ...resource.args };
|
|
@@ -292,35 +303,32 @@ async function dispatchCommand(parsed, dependencies) {
|
|
|
292
303
|
}
|
|
293
304
|
let result = await call(running.endpoint, name, parameters, requestOptions);
|
|
294
305
|
if (command === "tasks" && action === "get" && result?.session === null) {
|
|
295
|
-
throw new QzError("
|
|
306
|
+
throw new QzError("TASK_NOT_FOUND", TASK_NOT_FOUND_MESSAGE, missingTaskDetails(id, clientId));
|
|
296
307
|
}
|
|
297
308
|
if (command === "tasks" && action === "create" && options.wait) {
|
|
298
|
-
const sessionId = result?.session?.
|
|
299
|
-
if (!sessionId) throw new QzError("
|
|
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.");
|
|
300
311
|
await dependencies.onTaskCreated?.(result);
|
|
301
312
|
result = { ...result, snapshot: await observe(sessionId) };
|
|
302
313
|
}
|
|
303
314
|
return { result };
|
|
304
315
|
}
|
|
305
316
|
|
|
306
|
-
export function errorPayload(error) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
} };
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
if (!payload.hint && code === "BRIDGE_CLIENT_REQUIRED") payload.hint = "Run qz status to list browser IDs, then repeat with --client ID.";
|
|
319
|
-
if (["not_connected", "connection_error", "connection_unavailable", "connection_pending", "connection_changed", "browser_open_failed", "BRIDGE_NOT_CONNECTED", "BRIDGE_CLOSED"].includes(code)) {
|
|
320
|
-
Object.assign(payload, { guide: GUIDE_URL, install: INSTALL_URL,
|
|
321
|
-
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 });
|
|
322
329
|
}
|
|
323
|
-
|
|
330
|
+
if (Object.keys(details).length) payload.details = details;
|
|
331
|
+
return payload;
|
|
324
332
|
}
|
|
325
333
|
|
|
326
334
|
export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
@@ -329,15 +337,20 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
329
337
|
const env = dependencies.env || process.env;
|
|
330
338
|
// Preserve explicit machine output even when argument validation itself fails.
|
|
331
339
|
let mode = outputMode({ json: argv.includes("--json"), plain: argv.includes("--plain"), "no-color": argv.includes("--no-color") }, stdout, env);
|
|
332
|
-
let live;
|
|
340
|
+
let live, parsed;
|
|
333
341
|
try {
|
|
334
|
-
|
|
342
|
+
parsed = parseCommand(argv);
|
|
335
343
|
mode = outputMode(parsed.options, stdout, env);
|
|
336
344
|
if (parsed.command === "help") {
|
|
337
345
|
const help = renderHelp(parsed.helpTopic, mode);
|
|
338
346
|
stdout.write(parsed.options.json ? `${JSON.stringify({ ok: true, result: { help } })}\n` : help);
|
|
339
347
|
return 0;
|
|
340
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
|
+
}
|
|
341
354
|
if (parsed.options.wait) live = createLiveOutput(parsed, { stdout, stderr, ...mode });
|
|
342
355
|
const result = await executeCommand(parsed, { ...dependencies,
|
|
343
356
|
onTaskCreated: async task => { live?.created(task); await dependencies.onTaskCreated?.(task); },
|
|
@@ -348,17 +361,21 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
348
361
|
},
|
|
349
362
|
});
|
|
350
363
|
live?.finish();
|
|
364
|
+
if (result.stderr) stderr.write(result.stderr);
|
|
351
365
|
stdout.write(result.text ?? (mode.json ? `${JSON.stringify({ ok: true, result: result.result })}\n`
|
|
352
366
|
: renderResult({ ...parsed, presentation: { ...parsed.presentation, streamed: live?.streamed } }, result.result, mode)));
|
|
353
367
|
const status = result.result?.snapshot?.status || result.result?.snapshot?.runtime?.status;
|
|
368
|
+
if (Number.isInteger(result.exitCode) && result.exitCode > 0) return result.exitCode;
|
|
354
369
|
if (result.result?.accepted === false && !result.result?.requiresUserAction) return 1;
|
|
355
370
|
return ["error", "failed", "aborted", "cancelled", "canceled", "interrupted"].includes(status) ? 1 : 0;
|
|
356
371
|
} catch (error) {
|
|
357
372
|
live?.finish();
|
|
358
|
-
const
|
|
359
|
-
|
|
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`);
|
|
360
377
|
else stderr.write(renderError(payload, { ...mode, color: mode.color && Boolean(stderr.isTTY) }));
|
|
361
|
-
return payload.
|
|
378
|
+
return payload.code === "INVALID_ARGUMENTS" ? 2 : !scriptCommand && payload.code === "INTERRUPTED" ? 130 : 1;
|
|
362
379
|
}
|
|
363
380
|
}
|
|
364
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("
|
|
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("
|
|
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("
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
169
|
+
throw new QzError("TOOL_ERROR", typeof value === "string" ? value : "The browser tool failed.", { result: value });
|
|
128
170
|
}
|
|
129
171
|
return value;
|
|
130
172
|
};
|