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/README.md +41 -35
- package/package.json +5 -2
- package/src/args.mjs +57 -16
- package/src/bin.mjs +2 -2
- package/src/catalog.json +73 -10
- package/src/cli-resources.mjs +2 -2
- package/src/cli.mjs +146 -123
- package/src/client.mjs +61 -19
- package/src/commands.mjs +45 -10
- 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 +158 -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 +4 -4
- package/src/terminal.mjs +68 -18
- package/src/tools.mjs +148 -30
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 {
|
|
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
|
|
|
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
|
-
|
|
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.
|
|
50
|
+
if (input.readableEnded) end();
|
|
51
|
+
else input.resume();
|
|
40
52
|
});
|
|
41
53
|
}
|
|
42
54
|
|
|
43
|
-
const UPGRADE_HINT = "
|
|
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("
|
|
47
|
-
"This qz service is unsupported.
|
|
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
|
|
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();
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
172
|
-
if (!args || Array.isArray(args) || typeof args !== "object") throw new QzError("
|
|
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("
|
|
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
|
|
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")
|
|
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("
|
|
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 =
|
|
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 === "
|
|
210
|
-
|| (error.code === "
|
|
211
|
-
throw new QzError("
|
|
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" ? "
|
|
229
|
-
if (code === "
|
|
230
|
-
const changed = code === "
|
|
231
|
-
throw new QzError(code, code === "
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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?.
|
|
301
|
-
if (!sessionId) throw new QzError("
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
} };
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
|
|
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.
|
|
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("
|
|
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
|
};
|