qz-browser-agent 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,8 +9,17 @@ 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
- connect: { options: [...request, "port", "no-open", "name"], summary: "Connect the browser",
12
- help: ["qz connect [--client NAME|UUID] [--port PORT] [--timeout SECONDS]", "Reuse a connected browser or open the connection page for first pairing.", "qz connect --no-open [--name NAME] --json", "Return a binding URL immediately. Open it in the target browser profile.", "--name assigns or changes that profile's name. Without --no-open, its binding page opens immediately.", "Use --port for a custom local service port. An existing service remains running.", "qz browsers"],
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.",
@@ -49,7 +59,7 @@ export const COMMANDS = {
49
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."],
50
60
  },
51
61
  cancel: { options: request, summary: "Stop a task",
52
- help: ["qz cancel TASK_ID", "Stop a task's active run.", "qz stop stops the local service instead."],
62
+ help: ["qz cancel TASK_ID", "Stop a task's active run.", "Use qz session cancel ID to end a direct-drive session."],
53
63
  },
54
64
  data: { options: request, summary: "List, export, or delete tables",
55
65
  help: ["qz data [list]", "List saved data tables with their exact names.",
@@ -109,16 +119,41 @@ export const COMMANDS = {
109
119
  status: { options: [], summary: "Check the connection",
110
120
  help: ["qz status", "Show the local service and browser connection status."],
111
121
  },
112
- mcp: { options: [], summary: "Print the MCP URL",
113
- help: ["qz mcp [--json]", "Print the running service's MCP URL for an external agent.", "Run qz connect first."],
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
+ },
114
134
  },
115
- install: { options: [], summary: "Install the Chrome extension",
116
- help: ["qz install", "Open qz in the Chrome Web Store.", "Click Add to Chrome, then run qz connect."],
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'"],
117
144
  },
118
- stop: { options: [], summary: "Stop the local service",
119
- help: ["qz stop", "Stop the local service and disconnect all browsers.", "Use qz cancel TASK_ID to stop a task.", "qz connect"],
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."],
120
155
  },
121
- help: { options: [] },
156
+ help: { options: ["lang"] },
122
157
  };
123
158
 
124
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
+ }