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/commands.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const request = ["timeout", "client"];
|
|
2
|
+
export const DRIVE_SESSION_REQUIRED = "Pass the sessionId from qz session create.";
|
|
2
3
|
const waiting = [...request, "wait", "stream"];
|
|
3
4
|
const output = ["json", "plain", "no-color", "help"];
|
|
4
5
|
const filename = options => options.filename ? { filename: options.filename } : {};
|
|
@@ -8,13 +9,22 @@ const route = (tool, args = () => ({}), options = []) => ({ tool, args, options
|
|
|
8
9
|
// CLI syntax, documentation, and direct MCP routes have one declaration per command.
|
|
9
10
|
// Task execution and form responses retain their explicit handlers in cli.mjs.
|
|
10
11
|
export const COMMANDS = {
|
|
11
|
-
|
|
12
|
-
help: ["qz
|
|
12
|
+
cleanup: { options: [], summary: "Stop local services and clean runtime files",
|
|
13
|
+
help: ["qz cleanup", "Stop registered qz local services across installations and remove their runtime files and logs.",
|
|
14
|
+
"Uses ~/.qz-browser-agent/run, or QZ_STATE_DIR when set. Browser registrations, tabs, tasks and saved data are preserved.",
|
|
15
|
+
"Active CLI requests and direct-drive scripts are interrupted. Tasks running in the side panel continue.",
|
|
16
|
+
"The next browser command starts its service automatically. Use qz cancel TASK_ID or qz session cancel ID to stop a task.",
|
|
17
|
+
"Services whose identity cannot be verified are reported and left running; an incomplete cleanup exits with code 1."],
|
|
18
|
+
},
|
|
19
|
+
connect: { options: [...request, "port", "name"], summary: "Print a browser connection URI",
|
|
20
|
+
help: ["qz connect uri [--name NAME | --client NAME|UUID] [--port PORT]", "Print the connection URL without opening a browser or interrupting tasks.",
|
|
21
|
+
"Open the URL in the intended browser profile with qz installed. --name names a new profile; --client selects a registered profile.",
|
|
22
|
+
"Session and script commands prepare their connection automatically. Use this URL when choosing among browser profiles."],
|
|
13
23
|
},
|
|
14
24
|
create: { options: [...waiting, "title", "parent"], summary: "Start a task",
|
|
15
25
|
help: ['qz create "PROMPT" [--title TITLE] [--wait | --stream]', "Start a browser task. The title is optional.",
|
|
16
26
|
"--title, -t Set the task title. --parent TASK_ID creates a child task.",
|
|
17
|
-
"--wait
|
|
27
|
+
"--wait Follow the task across runs until its work completes and accepted message queues are empty; user-input pauses keep waiting.", "--stream Show responses as they arrive; also wait.",
|
|
18
28
|
"Use - as the prompt to read stdin.",
|
|
19
29
|
'qz create "Compare these two pages." --stream', 'qz create "Summarize this page." --title Research --wait'],
|
|
20
30
|
},
|
|
@@ -22,7 +32,8 @@ export const COMMANDS = {
|
|
|
22
32
|
help: ['qz send TASK_ID "MESSAGE" [--steer] [--wait | --stream]', "Send a follow-up or steer a specific task.",
|
|
23
33
|
"A busy task queues a follow-up for its next run. --steer delivers the message to the current run at its next safe boundary.",
|
|
24
34
|
"Both start a new run when the task is idle. Pending questions and permissions still require a response.",
|
|
25
|
-
"Use - as the message to read stdin. --wait follows the
|
|
35
|
+
"Use - as the message to read stdin. --wait follows the task until its work completes and queues are empty; removed queued messages no longer block completion.",
|
|
36
|
+
"--stream also shows responses from this message onward, including later runs.",
|
|
26
37
|
'qz send TASK_ID "Add the source links." --stream', 'qz send TASK_ID "Focus on open-source options." --steer --stream'],
|
|
27
38
|
},
|
|
28
39
|
tasks: { options: [...request, "limit"], summary: "List tasks",
|
|
@@ -32,22 +43,23 @@ export const COMMANDS = {
|
|
|
32
43
|
help: ["qz show TASK_ID", "Show task status, the latest response, and any pending form or permission request.", "Pending input includes field IDs, option values, and a submission template. Use --json for the complete snapshot.", "qz show TASK_ID --json"],
|
|
33
44
|
},
|
|
34
45
|
watch: { options: [...request, "stream"], summary: "Wait for a task",
|
|
35
|
-
help: ["qz watch TASK_ID [--stream]", "
|
|
46
|
+
help: ["qz watch TASK_ID [--stream]", "Follow an existing task across runs until its work completes and accepted message queues are empty; user-input pauses keep waiting.", "--stream Show assistant responses as they arrive.", "Task failure or stopping ends observation. Timeout or Ctrl-C ends watching without cancelling the task.", "Resume with the returned watch command for the same task and browser; observation never resends messages."],
|
|
36
47
|
},
|
|
37
48
|
submit: { options: [...waiting, "wait-id"], summary: "Answer a form",
|
|
38
49
|
help: ["qz submit TASK_ID JSON [--wait | --stream]", "Submit answers to the current form. Use - to read JSON from stdin.",
|
|
39
50
|
"--wait-id ID Bind the response to a previously read wait. Otherwise qz reads the current wait before submitting.",
|
|
51
|
+
"--wait and --stream follow the task across runs until its work completes and accepted message queues are empty.",
|
|
40
52
|
'qz submit TASK_ID \'{"selectedIds":["option-a"]}\' --stream'],
|
|
41
53
|
},
|
|
42
54
|
approve: { options: [...waiting, "wait-id"], summary: "Continue a waiting task",
|
|
43
55
|
help: ["qz approve TASK_ID [--wait | --stream]", "Continue after completing a requested action.",
|
|
44
|
-
"Review app consent notices with qz show before approving. Native browser permissions require a click in the qz side panel.", "--wait-id ID Respond only to this wait."],
|
|
56
|
+
"Review app consent notices with qz show before approving. Native browser permissions require a click in the qz side panel.", "--wait-id ID Respond only to this wait.", "--wait and --stream follow the task across runs until its work completes and accepted message queues are empty."],
|
|
45
57
|
},
|
|
46
58
|
reject: { options: [...waiting, "wait-id"], summary: "Decline a pending request",
|
|
47
|
-
help: ["qz reject TASK_ID [--wait-id ID] [--wait | --stream]", "Decline the pending request and
|
|
59
|
+
help: ["qz reject TASK_ID [--wait-id ID] [--wait | --stream]", "Decline the pending request. --wait and --stream follow the task across runs until its work completes and accepted message queues are empty. Use qz cancel TASK_ID to stop the whole task."],
|
|
48
60
|
},
|
|
49
61
|
cancel: { options: request, summary: "Stop a task",
|
|
50
|
-
help: ["qz cancel TASK_ID", "Stop a task's active run.", "qz
|
|
62
|
+
help: ["qz cancel TASK_ID", "Stop a task's active run.", "Use qz session cancel ID to end a direct-drive session."],
|
|
51
63
|
},
|
|
52
64
|
data: { options: request, summary: "List, export, or delete tables",
|
|
53
65
|
help: ["qz data [list]", "List saved data tables with their exact names.",
|
|
@@ -107,16 +119,41 @@ export const COMMANDS = {
|
|
|
107
119
|
status: { options: [], summary: "Check the connection",
|
|
108
120
|
help: ["qz status", "Show the local service and browser connection status."],
|
|
109
121
|
},
|
|
110
|
-
|
|
111
|
-
help: ["qz
|
|
122
|
+
session: { options: request, summary: "Create, inspect, or cancel a browser session",
|
|
123
|
+
help: ["qz session create --title TITLE", "Create a session with a readable title and print sessionId on stdout. --json also returns the session's available qz_skills.",
|
|
124
|
+
"qz session list", "List sessions and their current status.",
|
|
125
|
+
"qz session show ID", "Read one session's title and status.",
|
|
126
|
+
"qz session cancel ID [--keep TAB_REF ...]", "End the session and clean up its created tabs, including while a script is running. Repeat --keep to return selected tabs to the user.",
|
|
127
|
+
"Pass the same sessionId on every qz script --session ID call. Scripts never create or reuse a session implicitly."],
|
|
128
|
+
actions: {
|
|
129
|
+
create: route("qingzhou_create_drive_session", ({ options }) => ({ title: options.title.trim() }), ["title"]),
|
|
130
|
+
list: route("qingzhou_list_drive_sessions"),
|
|
131
|
+
show: route("qingzhou_get_drive_session", ({ id }) => ({ sessionId: id })),
|
|
132
|
+
cancel: route("qingzhou_stop_session", ({ id, options }) => ({ sessionId: id, ...(options.keep ? { keepTabRefs: options.keep } : {}) }), ["keep"]),
|
|
133
|
+
},
|
|
112
134
|
},
|
|
113
|
-
|
|
114
|
-
help: ["qz
|
|
135
|
+
script: { options: [...request, "session", "args"], summary: "Run a browser script",
|
|
136
|
+
help: ["qz script --session ID [FILE|-] [--args JSON]", "Run JavaScript orchestration outside the page. stdout is {ok,result,trace}: execution status, the returned business value (or error), and ordered trace messages.",
|
|
137
|
+
"--session ID is required. Create one session per task with qz session create --title TITLE and pass its sessionId.",
|
|
138
|
+
"Read a local file or use - for stdin. Write top-level await and return; --args passes a JSON value as args (aliased as input).",
|
|
139
|
+
"Call browser tools via tools.<name>(...) with explicit tabRef values; awaited calls return business values directly and throw on failure. Use tools.evaluate({tabRef,code,args}) for in-page DOM/window access.",
|
|
140
|
+
"Discover compact signatures with tools.list_tools() and full schemas with tools.list_tools({names:[...]}).",
|
|
141
|
+
"--timeout sets the script timeout in seconds (0 < seconds <= 3600) and defaults to 120. The request waits 60 seconds longer.",
|
|
142
|
+
"stdout is always {ok,result,trace}: ok reports execution success, result holds the returned value (null for undefined) or {code,message,hint?,details?}, and trace lists errors and explicit trace()/console output in order without levels. Exit codes: 0 success, 1 execution or connection failure, 2 invalid arguments.",
|
|
143
|
+
"qz script --session ID task.js --args '{\"query\":\"example\"}'", "qz script --session ID - <<'JS'"],
|
|
115
144
|
},
|
|
116
|
-
|
|
117
|
-
help: ["qz
|
|
145
|
+
skill: { options: ["lang"], summary: "Print the browser guide",
|
|
146
|
+
help: ["qz skill", "Print the direct-drive browser guide without connecting.",
|
|
147
|
+
"Read this guide once at the start of a browser task."],
|
|
148
|
+
},
|
|
149
|
+
mcp: { options: ["profile"], summary: "Print the MCP URL",
|
|
150
|
+
help: ["qz mcp [--profile full|drive] [--json]", "Print the MCP URL. The default catalog is the direct-drive set.",
|
|
151
|
+
"--profile full prints the task and diagnostic catalog.", "The local service starts automatically."],
|
|
152
|
+
},
|
|
153
|
+
install: { options: [], summary: "Install the Chrome extension",
|
|
154
|
+
help: ["qz install", "Open qz in the Chrome Web Store.", "Click Add to Chrome, then run qz session create --title TITLE."],
|
|
118
155
|
},
|
|
119
|
-
help: { options: [] },
|
|
156
|
+
help: { options: ["lang"] },
|
|
120
157
|
};
|
|
121
158
|
|
|
122
159
|
export function commandOptions(command, action = "list") {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { INSTALL_URL } from "./package-info.mjs";
|
|
4
|
+
|
|
5
|
+
// The published CLI carries the same sail mark as the browser extension.
|
|
6
|
+
export const CONNECT_LOGOS = new Map(["qz-logo.png", "qz-logo-day.png"].map(name =>
|
|
7
|
+
[`/${name}`, readFileSync(new URL(`./${name}`, import.meta.url))]));
|
|
8
|
+
|
|
9
|
+
// Browsers without qz never mark the status element, so the help appears after a short wait.
|
|
10
|
+
const CONNECT_SCRIPT = `
|
|
11
|
+
const help = document.querySelector("[data-qz-connection-help]");
|
|
12
|
+
const link = help.querySelector("[data-qz-connection-link]");
|
|
13
|
+
const copy = help.querySelector("[data-qz-copy-link]");
|
|
14
|
+
link.textContent = location.href;
|
|
15
|
+
setTimeout(() => { help.hidden = false; }, 3000);
|
|
16
|
+
copy.addEventListener("click", async () => {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.clipboard.writeText(location.href);
|
|
19
|
+
copy.dataset.copied = "";
|
|
20
|
+
copy.setAttribute("aria-label", "Copied");
|
|
21
|
+
setTimeout(() => { delete copy.dataset.copied; copy.setAttribute("aria-label", "Copy link"); }, 1500);
|
|
22
|
+
} catch {
|
|
23
|
+
getSelection().selectAllChildren(link);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export const CONNECT_PAGE_CSP = `default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; script-src 'sha256-${createHash("sha256").update(CONNECT_SCRIPT).digest("base64")}'; base-uri 'none'; frame-ancestors 'none'`;
|
|
29
|
+
|
|
30
|
+
export const CONNECT_PAGE = `<!doctype html>
|
|
31
|
+
<html lang="en">
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
34
|
+
<meta name="color-scheme" content="light dark">
|
|
35
|
+
<title>Connect qz</title>
|
|
36
|
+
<style>
|
|
37
|
+
:root { color-scheme: light dark; --bg: #f4f4f2; --surface: #fff; --text: #171717; --muted: #5f6368; --border: #e5e5e2; --green: #23815d; --red: #ba3c3c; }
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
body { margin: 0; min-height: 100svh; display: grid; place-items: center; padding: 32px 20px; background: var(--bg); color: var(--text); font: 16px/1.6 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
40
|
+
main { width: min(100%, 520px); padding: 44px; background: var(--surface); border: 1px solid var(--border); border-radius: 24px; box-shadow: 0 16px 48px #00000006, 0 2px 6px #00000003; }
|
|
41
|
+
.brand { display: flex; align-items: center; gap: 16px; font-size: 24px; font-weight: 650; letter-spacing: -.6px; }
|
|
42
|
+
.brand img { display: block; width: 64px; height: 64px; border-radius: 18px; }
|
|
43
|
+
h1 { margin: 34px 0 8px; font-size: clamp(25px, 5vw, 30px); line-height: 1.25; font-weight: 620; letter-spacing: -.8px; }
|
|
44
|
+
.intro { margin: 0; color: var(--muted); }
|
|
45
|
+
[data-qz-connection-status] { position: relative; display: grid; align-content: center; margin: 28px 0 0; min-height: calc(2lh + 34px); padding: 16px 18px 16px 46px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); font-size: 14px; overflow-wrap: anywhere; transition: background-color 300ms ease, border-color 300ms ease; }
|
|
46
|
+
[data-qz-connection-status]::before { content: ""; position: absolute; inset-inline-start: 18px; top: calc(50% - 6px); width: 12px; height: 12px; border: 1.5px solid var(--border); border-top-color: var(--muted); border-radius: 50%; animation: turn 1s linear infinite; transition: opacity 150ms ease; }
|
|
47
|
+
[data-qz-connection-status]::after { content: ""; position: absolute; inset-inline-start: 18px; top: calc(50% - 5px); width: 11px; height: 7px; border: solid var(--green); border-width: 0 0 2px 2px; opacity: 0; transform: rotate(-45deg) scale(.8); transition: opacity 220ms ease 80ms, transform 220ms cubic-bezier(.2,.7,.2,1) 80ms; }
|
|
48
|
+
[data-qz-connection-state="connected"] { border-color: color-mix(in srgb, var(--green) 22%, var(--border)); background: color-mix(in srgb, var(--green) 5%, var(--surface)); }
|
|
49
|
+
[data-qz-connection-state="connected"]::before { opacity: 0; animation-play-state: paused; }
|
|
50
|
+
[data-qz-connection-state="connected"]::after { opacity: 1; transform: rotate(-45deg); }
|
|
51
|
+
[data-qz-connection-state="connected"] [data-qz-connection-message] { animation: message-in 240ms ease-out 60ms both; }
|
|
52
|
+
[data-qz-connection-state="error"] { border-color: color-mix(in srgb, var(--red) 25%, var(--border)); }
|
|
53
|
+
[data-qz-connection-state="error"]::before { border: 0; width: 8px; height: 8px; top: calc(50% - 4px); background: var(--red); animation: none; }
|
|
54
|
+
footer { margin-top: 32px; padding-top: 24px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; animation: message-in 240ms ease-out both; }
|
|
55
|
+
footer p { margin: 0 0 6px; }
|
|
56
|
+
footer .title { color: var(--text); font-size: 14px; font-weight: 600; }
|
|
57
|
+
[data-qz-connection-status]:is([data-qz-extension], :not([data-qz-connection-state="connecting"])) ~ footer { display: none; }
|
|
58
|
+
.link { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 5px 5px 5px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg); }
|
|
59
|
+
.link code { flex: 1; min-width: 0; color: var(--text); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; user-select: all; }
|
|
60
|
+
.link button { flex: none; display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; }
|
|
61
|
+
.link button:hover { background: var(--surface); color: var(--text); }
|
|
62
|
+
.link button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
|
63
|
+
.link svg { width: 16px; height: 16px; }
|
|
64
|
+
.link [data-icon="check"], .link [data-copied] [data-icon="copy"] { display: none; }
|
|
65
|
+
.link button[data-copied] { color: var(--green); }
|
|
66
|
+
.link [data-copied] [data-icon="check"] { display: block; }
|
|
67
|
+
a { color: var(--text); text-underline-offset: 4px; text-decoration-color: var(--border); }
|
|
68
|
+
a:hover { text-decoration-color: currentColor; }
|
|
69
|
+
a:focus-visible { outline: 2px solid var(--text); outline-offset: 4px; border-radius: 2px; }
|
|
70
|
+
@keyframes turn { to { transform: rotate(360deg); } }
|
|
71
|
+
@keyframes message-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
|
|
72
|
+
@media (prefers-reduced-motion: reduce) { [data-qz-connection-status], [data-qz-connection-status]::before, [data-qz-connection-status]::after, [data-qz-connection-message], footer { animation: none !important; transition: none !important; } }
|
|
73
|
+
@media (max-width: 420px) { body { padding: 20px 16px; } main { padding: 30px 24px; border-radius: 20px; } [data-qz-connection-status] { min-height: calc(3lh + 34px); } }
|
|
74
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #171717; --surface: #212121; --text: #f2f2f0; --muted: #a5a5a1; --border: #343434; --green: #6fc6a1; --red: #ec8b8b; } main { box-shadow: 0 16px 48px #00000020; } }
|
|
75
|
+
</style>
|
|
76
|
+
<main>
|
|
77
|
+
<div class="brand"><picture><source media="(prefers-color-scheme: dark)" srcset="/qz-logo.png"><img src="/qz-logo-day.png" alt="" width="64" height="64"></picture><div>qz</div></div>
|
|
78
|
+
<h1>Connect your browser</h1>
|
|
79
|
+
<p class="intro">Keep this page open while qz connects.</p>
|
|
80
|
+
<p data-qz-connection-status data-qz-connection-state="connecting" role="status" aria-live="polite"><span data-qz-connection-message>Connecting to the qz browser extension…</span></p>
|
|
81
|
+
<footer data-qz-connection-help hidden>
|
|
82
|
+
<p class="title">Not connecting?</p>
|
|
83
|
+
<p><a href="${INSTALL_URL}" target="_blank" rel="noopener noreferrer">Install qz from the Chrome Web Store</a>, then reload this page.</p>
|
|
84
|
+
<p>Using qz in another browser or profile? Open this link there:</p>
|
|
85
|
+
<div class="link"><code data-qz-connection-link></code><button type="button" data-qz-copy-link aria-label="Copy link" title="Copy link"><svg data-icon="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg><svg data-icon="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></button></div>
|
|
86
|
+
</footer>
|
|
87
|
+
</main>
|
|
88
|
+
<script>${CONNECT_SCRIPT}</script>
|
|
89
|
+
</html>`;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { PROTOCOL_VERSION, QzError, readHealth } from "./client.mjs";
|
|
3
|
+
import { ensureDaemon, findDaemon, restartDaemon } from "./daemon.mjs";
|
|
4
|
+
import { openConnectionBrowser } from "./open-browser.mjs";
|
|
5
|
+
|
|
6
|
+
import { SCRIPT_API_VERSION } from "./script-call.mjs";
|
|
7
|
+
|
|
8
|
+
const offlineCodes = new Set(["BRIDGE_NOT_CONNECTED", "BRIDGE_CLIENT_OFFLINE", "BRIDGE_CLIENT_DISCONNECTED", "BRIDGE_REQUEST_TIMEOUT", -32001]);
|
|
9
|
+
|
|
10
|
+
/** Upgrade a verified service at most once, before any browser operation is submitted. */
|
|
11
|
+
export async function prepareService({ options = {}, script = false } = {}, dependencies = {}) {
|
|
12
|
+
const signal = dependencies.signal;
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const find = dependencies.findDaemon || findDaemon;
|
|
15
|
+
const ensure = dependencies.ensureDaemon || ensureDaemon;
|
|
16
|
+
let running = await find({ allowIncompatible: true });
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
if (!running) {
|
|
19
|
+
running = await ensure({ ...(options.port === undefined ? {} : { preferredPort: options.port }), signal });
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
}
|
|
22
|
+
const compatible = service => service.compatible !== false
|
|
23
|
+
&& (service.health.protocolVersion === undefined || service.health.protocolVersion === PROTOCOL_VERSION)
|
|
24
|
+
&& service.health.features?.browserPairing === 1 && (!script || service.health.features?.scriptApiVersion >= SCRIPT_API_VERSION);
|
|
25
|
+
if (!compatible(running)) {
|
|
26
|
+
running = await (dependencies.restartDaemon || restartDaemon)({ expected: running, preferredPort: Number(new URL(running.endpoint).port), signal });
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
if (!compatible(running)) throw new QzError("UPGRADE_REQUIRED", "The local qz service is incompatible. No browser operation was submitted.", {
|
|
29
|
+
hint: "Update the installed CLI and browser extension, then retry the command.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return running;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function connectionUri(running, options = {}) {
|
|
36
|
+
const url = new URL("/qz-connect", running.endpoint);
|
|
37
|
+
let name = options.name;
|
|
38
|
+
if (options["client-id"]) {
|
|
39
|
+
const entry = [...(running.health.status?.browsers || []), ...(running.health.status?.clients || [])]
|
|
40
|
+
.find(client => client.clientId === options["client-id"] || client.name === options["client-id"]);
|
|
41
|
+
if (!entry) throw new QzError("CLIENT_NOT_FOUND", "The selected browser is not registered. Run qz browsers to inspect names and UUIDs.");
|
|
42
|
+
name = entry.name;
|
|
43
|
+
}
|
|
44
|
+
if (name) url.searchParams.set("name", name);
|
|
45
|
+
return url.href;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Select one responding identity; every later call in the command keeps that UUID. */
|
|
49
|
+
export async function prepareConnection({ options = {}, timeoutSeconds = 30, sessionId = "", script = false } = {}, dependencies = {}) {
|
|
50
|
+
const running = await prepareService({ options, script }, dependencies);
|
|
51
|
+
const startedAt = Date.now(), commandDeadline = startedAt + timeoutSeconds * 1000;
|
|
52
|
+
let deadline = Math.min(commandDeadline, startedAt + 30000), reconnectUntil = 0;
|
|
53
|
+
const signal = dependencies.signal;
|
|
54
|
+
const selector = options["client-id"];
|
|
55
|
+
const registered = selector ? [...(running.health.status?.browsers || []), ...(running.health.status?.clients || [])]
|
|
56
|
+
.find(client => client.clientId === selector || client.name === selector) : undefined;
|
|
57
|
+
if (selector && !registered) throw new QzError("CLIENT_NOT_FOUND", "The selected browser is not registered. Run qz browsers to inspect names and UUIDs.");
|
|
58
|
+
const clientId = registered?.clientId;
|
|
59
|
+
const uri = connectionUri(running, options);
|
|
60
|
+
const call = async (name, args) => {
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
const timeoutMs = Math.max(1, Math.min(2000, deadline - Date.now()));
|
|
63
|
+
return dependencies.callTool(running.endpoint, name, { ...args, timeoutSeconds: timeoutMs / 1000 }, { timeoutMs, signal });
|
|
64
|
+
};
|
|
65
|
+
const locate = async health => {
|
|
66
|
+
const clients = (health.status?.clients || []).filter(client => !clientId || client.clientId === clientId);
|
|
67
|
+
const matches = [];
|
|
68
|
+
for (const client of clients) {
|
|
69
|
+
try { await call("qingzhou_call", { method: "ping", clientId: client.clientId }); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
if (!offlineCodes.has(error.code)) throw error;
|
|
73
|
+
if (sessionId && clients.length > 1) throw error;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// A unique connection retains the tool producer's authoritative occupied/ended checks.
|
|
77
|
+
if (!sessionId || selector || clients.length === 1) return client.clientId;
|
|
78
|
+
try {
|
|
79
|
+
await call("qingzhou_get_drive_session", { sessionId, clientId: client.clientId });
|
|
80
|
+
matches.push(client.clientId);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== "DRIVE_SESSION_NOT_FOUND") throw error;
|
|
83
|
+
const snapshot = await call("qingzhou_get_session_snapshot", { sessionId, clientId: client.clientId, compact: true, tail: true });
|
|
84
|
+
if (snapshot?.session) matches.push(client.clientId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (matches.length > 1) throw new QzError("SELECTION_REQUIRED", "This session exists in more than one browser. Select its browser with --client NAME|UUID.");
|
|
88
|
+
if (matches.length === 1) return matches[0];
|
|
89
|
+
if (sessionId && clients.length > 1) throw new QzError("DRIVE_SESSION_NOT_FOUND", "The session was not found in the connected browsers.", {
|
|
90
|
+
hint: "Check the session ID and use --client NAME|UUID to select its browser.",
|
|
91
|
+
});
|
|
92
|
+
return "";
|
|
93
|
+
};
|
|
94
|
+
const observeReconnect = health => {
|
|
95
|
+
const waiting = (health.status?.connecting || []).filter(client => !clientId || client.clientId === clientId);
|
|
96
|
+
const retryAfterMs = Math.max(0, ...waiting.map(client => Number.isFinite(client.retryAfterMs) ? client.retryAfterMs : 0));
|
|
97
|
+
reconnectUntil = retryAfterMs > 0 ? Math.min(commandDeadline, Date.now() + retryAfterMs) : 0;
|
|
98
|
+
deadline = Math.max(deadline, reconnectUntil);
|
|
99
|
+
};
|
|
100
|
+
observeReconnect(running.health);
|
|
101
|
+
let selected = await locate(running.health);
|
|
102
|
+
if (selected) return { running, clientId: selected };
|
|
103
|
+
const attempted = [];
|
|
104
|
+
let opened = Boolean(selector);
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
if (!opened && Date.now() >= reconnectUntil) {
|
|
108
|
+
const browser = await (dependencies.openConnectionBrowser || openConnectionBrowser)(uri, {
|
|
109
|
+
signal, timeoutMs: Math.max(1, Math.min(10000, deadline - Date.now())),
|
|
110
|
+
});
|
|
111
|
+
attempted.push(browser.browser || "System default browser");
|
|
112
|
+
opened = true;
|
|
113
|
+
}
|
|
114
|
+
if (Date.now() >= deadline) break;
|
|
115
|
+
const remainingMs = deadline - Date.now();
|
|
116
|
+
let health;
|
|
117
|
+
try {
|
|
118
|
+
health = await (dependencies.readHealth || readHealth)(running.endpoint, {
|
|
119
|
+
timeoutMs: Math.max(1, Math.ceil(Math.min(1000, remainingMs))), signal,
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
signal?.throwIfAborted();
|
|
123
|
+
// The read timer can fire just before Date.now() reaches the deadline it was sized to.
|
|
124
|
+
const budgetExpired = remainingMs <= 1000 && [error?.name, error?.cause?.name].includes("TimeoutError");
|
|
125
|
+
if (budgetExpired || Date.now() >= deadline) break;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
if (Date.now() >= deadline) break;
|
|
130
|
+
if (running.instanceId && health.instanceId !== running.instanceId) throw new QzError("CONNECTION_CHANGED", "The local service changed during connection. Retry the command.");
|
|
131
|
+
observeReconnect(health);
|
|
132
|
+
selected = await locate(health);
|
|
133
|
+
if (selected) return { running: { ...running, health }, clientId: selected };
|
|
134
|
+
await delay(Math.max(1, Math.min(200, deadline - Date.now())), undefined, { signal });
|
|
135
|
+
}
|
|
136
|
+
throw new QzError(selector ? "CLIENT_OFFLINE" : "CONNECTION_PENDING", `${selector ? "The selected browser is not responding." : "qz is waiting for its browser extension."} No browser operation was submitted.`, {
|
|
137
|
+
stage: "browser-connection",
|
|
138
|
+
...(registered ? { selectedBrowser: { clientId: registered.clientId, name: registered.name } } : {}),
|
|
139
|
+
attemptedBrowsers: attempted, operationSubmitted: false,
|
|
140
|
+
url: uri,
|
|
141
|
+
hint: selector
|
|
142
|
+
? "Open qz in the selected browser profile, then retry. Use qz connect uri --client NAME|UUID to reconnect that profile."
|
|
143
|
+
: "Check the connection page in your default browser, then retry. Use qz connect uri to connect a different supported browser profile.",
|
|
144
|
+
});
|
|
145
|
+
}
|