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.
@@ -1,8 +1,9 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
 
3
- const STOP_STATUSES = new Set(["completed", "error", "failed", "aborted", "cancelled", "interrupted"]);
3
+ const FAILURE_STATUSES = new Set(["error", "failed", "aborted", "cancelled", "interrupted"]);
4
4
  const ACTIVE_STATUSES = new Set(["pending", "running", "waiting_user", "paused"]);
5
5
  const INPUT_STATUSES = new Set(["waiting_user", "paused"]);
6
+ const QUEUE_FIELDS = ["pendingFollowUps", "pendingFollowUpMessages", "pendingSteerings", "pendingSteeringMessages"];
6
7
  const record = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7
8
 
8
9
  export const TASK_NOT_FOUND_MESSAGE = "The task was not found in the selected browser. It may have been deleted, the task ID may be incorrect, or a different browser may be selected.";
@@ -13,6 +14,11 @@ function taskStatus(snapshot) {
13
14
  return status.toLowerCase() === "canceled" ? "cancelled" : status.toLowerCase();
14
15
  }
15
16
 
17
+ function hasPendingWork(runtime) {
18
+ return runtime.running === true || runtime.pendingStart === true
19
+ || QUEUE_FIELDS.some(key => Array.isArray(runtime[key]) && runtime[key].length > 0);
20
+ }
21
+
16
22
  function assistantText(content) {
17
23
  if (typeof content === "string") return content;
18
24
  return Array.isArray(content) ? content.filter(part => part?.type === "text")
@@ -37,12 +43,13 @@ function abortable(operation, signal) {
37
43
  }
38
44
 
39
45
  /** Observe snapshots only: timeout, interruption, and polling failures never cancel or restart a browser task.
46
+ * Follow one Session across runs until execution and its accepted queues are finished.
40
47
  * User input pauses execution, not observation; input events expose each pending request.
41
48
  * Total observation is unlimited by default; each snapshot request has its own finite deadline.
42
49
  * getSnapshot must request compact:false and tail:false so transcript identities and streaming text survive.
43
50
  */
44
51
  export async function waitForTask({ sessionId, clientId, timeoutSeconds = null, requestTimeoutSeconds = 180, signal, getSnapshot, onUpdate,
45
- pollIntervalMs = 750, expectedRunId = "", expectedMessageId = "", previousRunId = "", ignoreWaitId = "", allowIdle = false } = {}) {
52
+ pollIntervalMs = 750, expectedRunId = "", expectedMessageId = "", messageWasQueued = false, ignoreWaitId = "", allowIdle = false } = {}) {
46
53
  if (!String(sessionId || "").trim()) throw new TypeError("sessionId is required");
47
54
  if (typeof getSnapshot !== "function") throw new TypeError("getSnapshot is required");
48
55
  if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) throw new TypeError("timeoutSeconds must be positive or null");
@@ -65,7 +72,7 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
65
72
  const requestDeadline = Date.now() + milliseconds;
66
73
  const requestExpiresFirst = deadline === null || requestDeadline < deadline;
67
74
  const requestTimeout = Object.assign(new Error(`Reading task ${sessionId} timed out after ${requestTimeoutSeconds} seconds. Check the browser connection, then resume watching this task.`),
68
- { code: "request_timeout" });
75
+ { code: "REQUEST_TIMEOUT" });
69
76
  // A request timeout ends observation too. One controller preserves the first reason,
70
77
  // and equal deadlines belong to the total timer. Downstream keeps its full request budget.
71
78
  const requestTimer = requestExpiresFirst ? setTimeout(() => controller.abort(requestTimeout), milliseconds) : null;
@@ -86,12 +93,11 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
86
93
  }
87
94
  };
88
95
  const messages = new Map();
89
- let selectedRunId = String(expectedRunId || "");
90
- let observedRun = false;
96
+ let firstRunId = String(expectedRunId || "");
97
+ let trackingReady = false;
91
98
  let observedActivity = false;
92
99
  let awaitingMessage = Boolean(expectedMessageId);
93
- let messageRunId = "";
94
- let awaitingResume = Boolean(ignoreWaitId);
100
+ let observedQueuedMessage = messageWasQueued;
95
101
  let lastStatus = "";
96
102
  let lastInput = "";
97
103
  const emit = async update => {
@@ -121,10 +127,10 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
121
127
  const snapshot = record(await readSnapshot());
122
128
  checkActive();
123
129
  if (snapshot.sessionId && snapshot.sessionId !== sessionId) {
124
- throw Object.assign(new Error("The task snapshot belongs to a different session."), { code: "task_changed" });
130
+ throw Object.assign(new Error("The task snapshot belongs to a different session."), { code: "TASK_CHANGED" });
125
131
  }
126
132
  if (Object.hasOwn(snapshot, "session") && snapshot.session === null) {
127
- throw Object.assign(new Error(TASK_NOT_FOUND_MESSAGE), { code: "task_deleted" });
133
+ throw Object.assign(new Error(TASK_NOT_FOUND_MESSAGE), { code: "TASK_DELETED" });
128
134
  }
129
135
  const runtime = record(snapshot.runtime);
130
136
  const canonicalInput = Object.hasOwn(snapshot, "input") ? record(snapshot.input) : null;
@@ -132,72 +138,68 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
132
138
  const status = taskStatus(snapshot);
133
139
  if (!status) {
134
140
  throw Object.assign(new Error("The task snapshot has no status. Check the browser connection, then resume watching this task."),
135
- { code: "invalid_response" });
141
+ { code: "INVALID_RESPONSE" });
136
142
  }
137
143
  const transcript = Array.isArray(snapshot.transcript) ? snapshot.transcript : Array.isArray(snapshot.tail) ? snapshot.tail : [];
138
144
  const submittedMessageIndex = expectedMessageId
139
145
  ? transcript.findIndex(entry => entry?.type === "user" && entry.clientMessageId === expectedMessageId) : -1;
140
- if (awaitingMessage) {
141
- const message = transcript[submittedMessageIndex];
142
- if (message?.runId) {
143
- messageRunId = String(message.runId);
144
- selectedRunId = messageRunId;
145
- awaitingMessage = false;
146
- } else {
147
- // A queued message may wait behind an earlier run's question or permission.
148
- // Keep that input actionable, but never mistake its answer for this message's result.
149
- if (status !== "completed") await emitStatus(status, runId, runtime);
150
- await emitInput(status, runId, runtime, canonicalInput);
151
- if (STOP_STATUSES.has(status) && status !== "completed") return snapshot;
152
- await pollingDelay();
153
- continue;
154
- }
146
+ if (awaitingMessage && QUEUE_FIELDS.some(key => Array.isArray(runtime[key])
147
+ && runtime[key].some(message => message?.clientMessageId === expectedMessageId))) observedQueuedMessage = true;
148
+ const submittedMessage = transcript[submittedMessageIndex];
149
+ if (awaitingMessage && submittedMessage?.runId) {
150
+ firstRunId = String(submittedMessage.runId);
151
+ awaitingMessage = false;
155
152
  }
156
- const previous = previousRunId && (!runId || runId === previousRunId);
157
- const awaitedRunId = messageRunId || expectedRunId;
158
- const awaitingExpected = awaitedRunId && runId !== awaitedRunId;
159
153
  const targetIndex = submittedMessageIndex >= 0 ? submittedMessageIndex
160
- : awaitedRunId ? transcript.findIndex(entry => entry?.runId === awaitedRunId) : -1;
161
- // A fast queued run can finish before the first poll. A later accepted user message
162
- // distinguishes that replacement from an older runtime snapshot still catching up.
163
- const passedTarget = awaitingExpected && runId && targetIndex >= 0
154
+ : firstRunId ? transcript.findIndex(entry => entry?.runId === firstRunId) : -1;
155
+ // The runtime can lag the transcript or skip whole runs between polls. Admit the
156
+ // target run or a proven successor once, then follow the Session without a run fence.
157
+ const passedTarget = runId && targetIndex >= 0
164
158
  && transcript.slice(targetIndex + 1).some(entry => entry?.type === "user" && entry.runId === runId);
165
- if ((observedRun && runId && selectedRunId !== runId) || passedTarget) {
166
- throw Object.assign(new Error("A different run replaced the task being watched. Use qz show to inspect its conversation."), {
167
- code: "task_changed", details: { expectedRunId: selectedRunId || awaitedRunId, actualRunId: runId },
168
- });
159
+ // Resolving a wait and persisting its updated snapshot are separate operations.
160
+ // Ignore that exact old wait even while its runtime lags the acknowledged run.
161
+ const oldWait = !trackingReady && ignoreWaitId && status === "waiting_user" && String(canonicalInput?.waitId || runtime.waitId || "") === ignoreWaitId;
162
+ if (oldWait) {
163
+ await pollingDelay();
164
+ continue;
169
165
  }
170
- if (!previous && !awaitingExpected) {
171
- if (runId) { selectedRunId = runId; observedRun = true; }
172
- // Resolving a wait and persisting its updated snapshot are separate operations.
173
- // Ignore that exact old wait until the resumed run or its next wait is visible.
174
- const oldWait = awaitingResume && status === "waiting_user" && String(canonicalInput?.waitId || runtime.waitId || "") === ignoreWaitId;
175
- if (oldWait) {
176
- await pollingDelay();
177
- continue;
178
- }
179
- awaitingResume = false;
180
- if (ACTIVE_STATUSES.has(status)) observedActivity = true;
181
- await emitStatus(status, runId, runtime);
182
- for (const [index, entry] of transcript.entries()) {
183
- // Steering can continue the same run. Only stream responses after this submitted message.
184
- if (expectedMessageId && (submittedMessageIndex < 0 || index <= submittedMessageIndex)) continue;
185
- if (entry?.type !== "assistant" || (entry.runId && selectedRunId && entry.runId !== selectedRunId)) continue;
186
- const messageId = messageIdentity(entry, index, selectedRunId);
187
- const fullText = assistantText(entry.content);
188
- const seen = messages.has(messageId);
189
- const previousText = messages.get(messageId) || "";
190
- if (fullText === previousText) continue;
191
- messages.set(messageId, fullText);
192
- const append = fullText.startsWith(previousText);
193
- await emit({ type: append ? "text" : "replace", sessionId, runId: String(entry.runId || runId), messageId,
194
- text: append ? fullText.slice(previousText.length) : fullText, fullText,
195
- newMessage: !seen, streaming: entry.streaming === true });
196
- }
166
+ if (!trackingReady && (awaitingMessage || (firstRunId && runId !== firstRunId && !passedTarget))) {
167
+ // Queue acceptance precedes these reads; pending precedes dequeue. A settled
168
+ // task with no trace of an accepted queued message means it was removed.
169
+ const removedMessage = awaitingMessage && observedQueuedMessage && submittedMessageIndex < 0
170
+ && status === "completed" && !hasPendingWork(runtime);
171
+ // A queued message can wait behind another run's question. Keep it actionable,
172
+ // but do not emit its earlier answer or accept its successful completion.
173
+ if (status !== "completed" || removedMessage) await emitStatus(status, runId, runtime);
197
174
  await emitInput(status, runId, runtime, canonicalInput);
198
- // A newly created session can report idle before its first run starts.
199
- if (STOP_STATUSES.has(status) || (status === "idle" && (observedActivity || allowIdle))) return snapshot;
175
+ if (FAILURE_STATUSES.has(status) || removedMessage) return snapshot;
176
+ await pollingDelay();
177
+ continue;
178
+ }
179
+ trackingReady = true;
180
+ if (!firstRunId && runId) firstRunId = runId;
181
+ if (ACTIVE_STATUSES.has(status)) observedActivity = true;
182
+ await emitStatus(status, runId, runtime);
183
+ for (const [index, entry] of transcript.entries()) {
184
+ // Steering can continue the same run. Only stream responses after this submitted message.
185
+ if (expectedMessageId && (submittedMessageIndex < 0 || index <= submittedMessageIndex)) continue;
186
+ if (entry?.type !== "assistant") continue;
187
+ if (!expectedMessageId && (targetIndex >= 0 ? index < targetIndex : entry.runId && entry.runId !== runId)) continue;
188
+ const messageId = messageIdentity(entry, index, firstRunId);
189
+ const fullText = assistantText(entry.content);
190
+ const seen = messages.has(messageId);
191
+ const previousText = messages.get(messageId) || "";
192
+ if (fullText === previousText) continue;
193
+ messages.set(messageId, fullText);
194
+ const append = fullText.startsWith(previousText);
195
+ await emit({ type: append ? "text" : "replace", sessionId, runId: String(entry.runId || runId), messageId,
196
+ text: append ? fullText.slice(previousText.length) : fullText, fullText,
197
+ newMessage: !seen, streaming: entry.streaming === true });
200
198
  }
199
+ await emitInput(status, runId, runtime, canonicalInput);
200
+ // A newly created session can report idle before its first run starts.
201
+ if (FAILURE_STATUSES.has(status) || (!hasPendingWork(runtime)
202
+ && (status === "completed" || (status === "idle" && (observedActivity || allowIdle))))) return snapshot;
201
203
  await pollingDelay();
202
204
  }
203
205
  } catch (error) {
package/src/terminal.mjs CHANGED
@@ -198,6 +198,29 @@ export function renderInputNotice(input, options = {}) {
198
198
  return out.finish();
199
199
  }
200
200
 
201
+ function formatUsage(usage) {
202
+ if (!usage || typeof usage !== "object") return "";
203
+ const count = value => {
204
+ const number = Number(value);
205
+ return Number.isFinite(number) ? String(Math.max(0, Math.trunc(number))) : "0";
206
+ };
207
+ const cost = Number(usage.costUsd);
208
+ const text = Number.isFinite(cost) ? cost.toFixed(6).replace(/\.?0+$/u, "") : "";
209
+ const shown = ["inputTokens", "outputTokens", "cachedTokens", "costUsd", "modelCalls", "toolCalls", "screenshots", "contextCompressions"]
210
+ .some(key => Number(usage[key]) > 0);
211
+ if (!shown) return "";
212
+ return [
213
+ `input ${count(usage.inputTokens)}`,
214
+ `output ${count(usage.outputTokens)}`,
215
+ `cached ${count(usage.cachedTokens)}`,
216
+ `cost $${text || "0"}`,
217
+ `model calls ${count(usage.modelCalls)}`,
218
+ `tool calls ${count(usage.toolCalls)}`,
219
+ `screenshots ${count(usage.screenshots)}`,
220
+ `compressions ${count(usage.contextCompressions)}`,
221
+ ].join(", ");
222
+ }
223
+
201
224
  function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
202
225
  const status = taskStatus(snapshot);
203
226
  out.line(label(status), tone(status));
@@ -212,6 +235,8 @@ function snapshotView(out, snapshot, { streamed = false, clientId = "" } = {}) {
212
235
  if (response && !streamed) {
213
236
  out.line(); out.line("Latest response", "muted"); out.line(response);
214
237
  }
238
+ const usage = formatUsage(snapshot.usage);
239
+ if (usage) out.field("Usage", usage);
215
240
  }
216
241
 
217
242
  function taskList(out, sessions, client = "") {
@@ -412,13 +437,14 @@ export function renderResult(parsed, result, options = {}) {
412
437
  if (action === "list" || !action) taskList(out, result?.sessions || [], client);
413
438
  else if (action === "get") snapshotView(out, result || {}, presentation);
414
439
  else if (action === "create") {
440
+ const sessionId = result?.session?.sessionId || result?.sessionId || result?.session?.id;
415
441
  if (result?.snapshot) snapshotView(out, result.snapshot, presentation);
416
442
  else {
417
443
  out.line(result?.started ? "Task queued" : "Task created", "accent");
418
- out.field("Task", result?.session?.id);
444
+ out.field("Task", sessionId);
419
445
  out.field("Title", single(result?.session?.title));
420
446
  }
421
- taskCommands(out, result?.session?.id || result?.snapshot?.sessionId, { client, cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
447
+ taskCommands(out, sessionId || result?.snapshot?.sessionId, { client, cancel: !result?.snapshot || ["pending", "running", "waiting_user", "waitingUser", "paused"].includes(taskStatus(result.snapshot)) });
422
448
  } else if (action === "stop") {
423
449
  out.line(result?.stopped ? "Task stopped" : "Task has no active run", "accent");
424
450
  out.field("Task", result?.sessionId || parsed.id);
@@ -456,16 +482,19 @@ export function renderResult(parsed, result, options = {}) {
456
482
  out.line("Service upgrade required for browser pairing.", "waiting");
457
483
  out.line(result.hint);
458
484
  } else if (result?.updateAvailable) {
459
- out.line("Service update available. Restart when your current work is finished:", "waiting");
460
- out.command("qz stop && qz connect");
461
- } else if (!result?.connected) out.command("qz connect");
485
+ out.line("Service update available. Compatible connections remain in use.", "waiting");
486
+ } else if (!result?.connected) out.command("qz connect uri");
487
+ } else if (command === "cleanup") {
488
+ out.line(result?.stopped || result?.removedFiles ? "Cleanup complete" : "Nothing to clean", "accent");
489
+ out.field("Services stopped", result?.stopped ?? 0);
490
+ out.field("Runtime files removed", result?.removedFiles ?? 0);
491
+ for (const service of result?.services || []) {
492
+ if (service.status === "stopped") out.field(`Stopped PID ${service.processId}`, service.endpoint || service.scopeId);
493
+ }
462
494
  } else if (command === "install") {
463
495
  out.line(result?.opened ? "Chrome Web Store opened" : "Install qz from the Chrome Web Store", "accent");
464
496
  if (!result?.opened) out.line(result?.url);
465
- out.line("Click Add to Chrome, then connect:"); out.command("qz connect");
466
- } else if (command === "stop") {
467
- out.line(result?.stopped ? "Local service stopped" : "Local service is already stopped", "accent");
468
- out.command("qz connect");
497
+ out.line("Click Add to Chrome, then create a session:"); out.command('qz session create --title "Browser task"');
469
498
  } else {
470
499
  out.line(JSON.stringify(result ?? null, null, 2));
471
500
  }
@@ -480,13 +509,18 @@ export function renderHelp(topic = "", options = {}) {
480
509
  for (const detail of details) { out.line(); out.line(detail, detail.startsWith("qz ") ? "accent" : undefined); }
481
510
  } else {
482
511
  out.line("qz · Your browser, from the terminal", "accent");
483
- out.line("Run tasks with the qz browser agent, manage results, and connect via MCP.");
512
+ out.line("Drive your browser with sessions and scripts, or delegate a task to the built-in agent.");
484
513
  out.line();
485
- for (const { help, summary: description } of Object.values(COMMANDS)) {
486
- if (!help) continue;
487
- const name = help[0].replace(/^qz /, "").replace(/ \[.*$/, "");
488
- if (out.width >= 64) out.line(` ${pad(name, 23)} ${description}`);
489
- else { out.command(`qz ${name}`); out.line(` ${description}`, "muted"); }
514
+ const entries = Object.entries(COMMANDS);
515
+ for (const direct of [true, false]) {
516
+ out.line(direct ? "Browser scripts" : "Connection, built-in agent and saved results", "accent");
517
+ for (const [command, { help, summary: description }] of entries) {
518
+ if (!help || (["session", "script"].includes(command)) !== direct) continue;
519
+ const name = help[0].replace(/^qz /, "").replace(/ \[.*$/, "");
520
+ if (out.width >= 64) out.line(` ${pad(name, 23)} ${description}`);
521
+ else { out.command(`qz ${name}`); out.line(` ${description}`, "muted"); }
522
+ }
523
+ out.line();
490
524
  }
491
525
  out.line(); out.command('qz create "Summarize this page." --stream');
492
526
  out.command('qz create "Compare these tabs." --title Research --wait');
@@ -503,15 +537,31 @@ export function renderHelp(topic = "", options = {}) {
503
537
  out.line("Version: qz --version (-v)", "muted");
504
538
  out.line("npm: npm run qz -- COMMAND [options]", "muted");
505
539
  out.shellNote();
540
+ if (!topic) {
541
+ out.line();
542
+ out.line("Full browser guide: qz skill", "muted");
543
+ }
506
544
  return out.finish();
507
545
  }
508
546
 
509
547
  export function renderError(payload, options = {}) {
510
548
  const out = output(options);
511
- const error = payload?.error || payload || {};
512
- const waiting = error.code === "connection_pending";
513
- out.line(waiting ? "Waiting for Chrome" : "qz error", waiting ? "waiting" : "error");
549
+ const value = payload?.error || payload || {};
550
+ const error = { ...value.details, ...value };
551
+ const waiting = error.code === "CONNECTION_PENDING";
552
+ out.line(waiting ? "Waiting for browser extension" : "qz error", waiting ? "waiting" : "error");
514
553
  out.line(error.message || "The command could not be completed.");
554
+ if (error.code === "CLEANUP_INCOMPLETE") {
555
+ for (const service of error.services || []) {
556
+ if (service.status === "skipped") out.field(service.endpoint || service.scopeId, service.message || service.code);
557
+ }
558
+ }
559
+ if (waiting || error.code === "CLIENT_OFFLINE") {
560
+ out.field("Stage", error.stage);
561
+ out.field("Browser", error.selectedBrowser?.name);
562
+ out.field("UUID", error.selectedBrowser?.clientId);
563
+ if (Array.isArray(error.attemptedBrowsers)) out.field("Browsers tried", error.attemptedBrowsers.slice(0, 3).join(", ") || "None");
564
+ }
515
565
  if (error.url) { out.line(); out.line(error.url, "accent"); }
516
566
  if (error.hint) { out.line(); out.line(error.hint); }
517
567
  if (error.command) out.script(error.command);
package/src/tools.mjs CHANGED
@@ -5,6 +5,9 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv
5
5
  import { BridgeError } from "./state.mjs";
6
6
  import { waitForTask } from "./task-stream.mjs";
7
7
  import { PACKAGE_VERSION } from "./package-info.mjs";
8
+ import { driveGuide } from "./drive-guide.mjs";
9
+ import { driveExtensionTimeoutSeconds } from "./drive-timeout.mjs";
10
+ export { driveExtensionTimeoutSeconds } from "./drive-timeout.mjs";
8
11
 
9
12
  // Public MCP tools use the extension's version 1 wire protocol.
10
13
  export const TOOL_CATALOG = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url), "utf8"))
@@ -50,7 +53,7 @@ function argumentsWithDefaults(definition, input) {
50
53
  }));
51
54
  }
52
55
 
53
- export async function executeMcpTool(state, name, input = {}, { signal, onTaskUpdate } = {}) {
56
+ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUpdate, clientName } = {}) {
54
57
  const args = validateMcpToolInput(name, input);
55
58
  if (name === "qingzhou_bridge_status") return state.status();
56
59
  if (name === "qingzhou_forget_browser") return state.forgetBrowser(args.clientId);
@@ -62,12 +65,12 @@ export async function executeMcpTool(state, name, input = {}, { signal, onTaskUp
62
65
  const connectionSignal = state.signal(connection.controller.signal);
63
66
  const operationSignal = signal ? AbortSignal.any([connectionSignal, signal]) : connectionSignal;
64
67
  operationSignal.throwIfAborted();
65
- const result = await executeBrowserTool(state, name, args, { signal: operationSignal, connectionSignal, onTaskUpdate });
68
+ const result = await executeBrowserTool(state, name, args, { signal: operationSignal, connectionSignal, onTaskUpdate, clientName });
66
69
  return args.clientId && (observableTools.has(name) || taskInputResults.has(name) || ["qingzhou_list_sessions", "qingzhou_get_user_input", "qingzhou_stop_session", "qingzhou_abort_session"].includes(name)) && result && typeof result === "object" && !Array.isArray(result)
67
70
  ? { ...result, clientId: args.clientId } : result;
68
71
  }
69
72
 
70
- async function executeBrowserTool(state, name, args, { signal, connectionSignal, onTaskUpdate }) {
73
+ async function executeBrowserTool(state, name, args, { signal, connectionSignal, onTaskUpdate, clientName }) {
71
74
  const observe = observableTools.has(name) && (name === "qingzhou_watch_task" || args.wait || args.stream);
72
75
  const asyncTool = name === "qingzhou_execute_tool" && args.wait === false;
73
76
  const options = { timeoutSeconds: args.timeoutSeconds, clientId: args.clientId, signal };
@@ -78,7 +81,7 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
78
81
  if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after waitTimeoutSeconds. The browser task may still be running.", "TimeoutError");
79
82
  return await waitForTask({ sessionId, clientId: args.clientId, signal, timeoutSeconds: remaining,
80
83
  requestTimeoutSeconds: args.timeoutSeconds || state.requestTimeoutSeconds || 180,
81
- expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId,
84
+ expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId, messageWasQueued: tracking.disposition === "queued",
82
85
  ignoreWaitId: tracking.ignoreWaitId, allowIdle: tracking.allowIdle,
83
86
  onUpdate: onTaskUpdate && (event => args.stream || !["text", "replace"].includes(event.type) ? onTaskUpdate(event) : undefined),
84
87
  getSnapshot: async ({ signal: requestSignal, timeoutSeconds }) => bindInputClient("qingzhou_get_session_snapshot",
@@ -88,21 +91,24 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
88
91
  });
89
92
  } catch (cause) {
90
93
  const code = typeof cause.code === "string" ? cause.code
91
- : cause.name === "TimeoutError" ? "timeout" : cause.name === "AbortError" ? "interrupted" : "task_wait_failed";
94
+ : cause.name === "TimeoutError" ? "TIMEOUT" : cause.name === "AbortError" ? "INTERRUPTED" : "TASK_WAIT_FAILED";
92
95
  const error = Object.assign(new Error(cause.message, { cause }), { code });
93
96
  error.details = { ...cause.details, sessionId, clientId: args.clientId,
94
- recovery: code === "task_deleted"
97
+ recovery: code === "TASK_DELETED"
95
98
  ? { tool: "qingzhou_list_sessions", arguments: { clientId: args.clientId } }
96
- : { tool: code === "task_changed" ? "qingzhou_get_session_snapshot" : "qingzhou_watch_task",
99
+ : { tool: code === "TASK_CHANGED" ? "qingzhou_get_session_snapshot" : "qingzhou_watch_task",
97
100
  arguments: { sessionId, clientId: args.clientId } } };
98
101
  throw error;
99
102
  }
100
103
  };
101
104
  if (name === "qingzhou_watch_task") return { sessionId: args.sessionId, snapshot: await watch(args.sessionId, { allowIdle: true }) };
102
105
  if (name === "qingzhou_get_tool") {
103
- const listed = await state.callExtension("list_tools", { sessionId: args.sessionId, timeoutSeconds: args.timeoutSeconds }, options);
104
- const tool = (Array.isArray(listed) ? listed : listed?.tools || []).find(tool => (tool.name || tool.function?.name) === args.name);
105
- if (!tool) throw new BridgeError("tool_not_found", `No browser tool named ${args.name}. Use qingzhou_list_tools to list available tools.`);
106
+ const tool = await state.callExtension("get_tool", {
107
+ sessionId: args.sessionId, name: args.name, timeoutSeconds: args.timeoutSeconds,
108
+ }, options);
109
+ if (!tool || typeof tool !== "object") {
110
+ throw new BridgeError("TOOL_NOT_FOUND", `No browser tool named ${args.name}. Use qingzhou_list_tools to list available tools.`);
111
+ }
106
112
  return tool;
107
113
  }
108
114
  if (name === "qingzhou_call") return state.callExtension(args.method.trim(), args.params || {}, options);
@@ -116,11 +122,35 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
116
122
  }
117
123
  if (name === "qingzhou_follow_up") params.images ||= [];
118
124
  if (name === "qingzhou_submit_wait_form") params.answers ||= {};
125
+ if (name === "qingzhou_create_drive_session") {
126
+ const displayName = String(clientName || "").trim();
127
+ if (displayName) params.clientName = displayName;
128
+ }
119
129
  if (name === "qingzhou_execute_tool") {
130
+ const scriptApiVersion = params.scriptApiVersion;
131
+ if (scriptApiVersion != null) {
132
+ const capabilities = await state.callExtension("capabilities", {}, options);
133
+ if (capabilities?.scriptApiVersion !== scriptApiVersion) {
134
+ throw Object.assign(new BridgeError("UPGRADE_REQUIRED", "The connected extension does not support this script API. No script was executed."), {
135
+ hint: "Update the CLI and extension together, then reconnect before running the script.",
136
+ });
137
+ }
138
+ }
120
139
  delete params.wait;
121
- params.args ||= {};
122
- return asyncTool ? { ...state.startExtensionJob("execute_tool", params, { ...options, connectionSignal }), sessionId: args.sessionId, clientId }
123
- : state.callExtension("execute_tool", params, options);
140
+ if (!Array.isArray(params.calls)) params.args ||= {};
141
+ const displayName = String(clientName || "").trim();
142
+ if (displayName) params.clientName = displayName;
143
+ const requestTimeout = driveExtensionTimeoutSeconds(params, args.timeoutSeconds || state.requestTimeoutSeconds || 180);
144
+ const callOptions = { ...options, timeoutSeconds: requestTimeout, cancelOnAbort: !asyncTool };
145
+ const invoke = () => asyncTool
146
+ ? { ...state.startExtensionJob("execute_tool", params, { ...callOptions, connectionSignal }), sessionId: args.sessionId, clientId }
147
+ : state.callExtension("execute_tool", params, callOptions);
148
+ try {
149
+ return await invoke();
150
+ } catch (error) {
151
+ if (error?.code === "DRIVE_SESSION_BUSY") return await invoke();
152
+ throw error;
153
+ }
124
154
  }
125
155
  const method = name === "qingzhou_abort_session" ? "stop_session" : name.slice("qingzhou_".length);
126
156
  let result = await state.callExtension(method, params, options);
@@ -133,16 +163,19 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
133
163
  }
134
164
  result = bindInputClient(name, result, clientId);
135
165
  if (observe) {
136
- const sessionId = name === "qingzhou_create_session" ? result?.session?.id || result?.sessionId : args.sessionId;
166
+ const sessionId = name === "qingzhou_create_session"
167
+ ? result?.session?.sessionId || result?.sessionId || result?.session?.id : args.sessionId;
137
168
  if (name === "qingzhou_create_session") {
138
- if (!sessionId) throw new BridgeError("invalid_response", "The task was created without an ID. Use qingzhou_list_sessions to find it before retrying.");
169
+ if (!sessionId) throw new BridgeError("INVALID_RESPONSE", "The task was created without an ID. Use qingzhou_list_sessions to find it before retrying.");
139
170
  await onTaskUpdate?.({ type: "created", sessionId, clientId, ...result });
140
171
  result = { ...result, snapshot: await watch(sessionId, { allowIdle: !args.firstMessage.trim() }) };
141
172
  } else if (name === "qingzhou_send_message" && result?.accepted === true) {
142
173
  if (typeof result.clientMessageId !== "string" || !result.clientMessageId.trim()) {
143
- throw new BridgeError("invalid_response", "The message was accepted without a tracking ID. Inspect the task before sending it again.");
174
+ throw Object.assign(new BridgeError("INVALID_RESPONSE", "The message was accepted without a tracking ID. Continue watching the same task before sending it again."), {
175
+ details: { sessionId, clientId, recovery: { tool: "qingzhou_watch_task", arguments: { sessionId, clientId } } },
176
+ });
144
177
  }
145
- result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId }) };
178
+ result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId, disposition: result.disposition }) };
146
179
  } else if (name === "qingzhou_respond_to_user_input" && (result?.accepted === true || result?.requiresUserAction)) {
147
180
  const snapshot = await watch(sessionId, { runId: result.runId || "", ignoreWaitId: result.requiresUserAction ? "" : args.waitId });
148
181
  result = result.requiresUserAction ? { sessionId, waitId: args.waitId, snapshot } : { ...result, snapshot };
@@ -154,44 +187,131 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
154
187
  /** Validate input against the advertised tool schema and apply its defaults. */
155
188
  export function validateMcpToolInput(name, input = {}) {
156
189
  const definition = definitions.get(name);
157
- if (!definition) throw Object.assign(new Error(`Unknown tool: ${name}. Read the MCP tool list for available names.`), { code: "invalid_arguments" });
190
+ if (!definition) throw Object.assign(new Error(`Unknown tool: ${name}. Read the MCP tool list for available names.`), { code: "INVALID_ARGUMENTS" });
158
191
  try {
159
192
  return argumentsWithDefaults(definition, input);
160
193
  } catch (error) {
161
- error.code = "invalid_arguments";
194
+ error.code = "INVALID_ARGUMENTS";
162
195
  throw error;
163
196
  }
164
197
  }
165
198
 
166
- export function resultToContent(result) {
199
+ function imageEntries(value) {
200
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.images)) return [];
201
+ return value.images.filter(image => image && typeof image.data === "string" && typeof image.mimeType === "string");
202
+ }
203
+
204
+ function withoutImages(value) {
205
+ if (!imageEntries(value).length) return value;
206
+ const { images: _images, ...rest } = value;
207
+ return rest;
208
+ }
209
+
210
+ function imageBlock(image, meta) {
211
+ return {
212
+ type: "image",
213
+ data: image.data,
214
+ mimeType: image.mimeType,
215
+ ...(meta && Object.keys(meta).length ? { _meta: meta } : {}),
216
+ };
217
+ }
218
+
219
+ function peelCallImages(value, callId, sink) {
220
+ const images = imageEntries(value);
221
+ if (!images.length) return value;
222
+ const placeholders = images.map((image, offset) => {
223
+ const meta = { ...(callId ? { id: callId } : {}), index: offset };
224
+ sink.push(imageBlock(image, meta));
225
+ return meta;
226
+ });
227
+ return { ...withoutImages(value), images: placeholders };
228
+ }
229
+
230
+ export function resultToContent(result, { toolResult = false } = {}) {
167
231
  if (result == null) return [];
168
- if (Array.isArray(result)) return result.flatMap(resultToContent);
169
- return [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }];
232
+ if (Array.isArray(result)) return result.flatMap(item => resultToContent(item));
233
+ if (typeof result !== "object") return [{ type: "text", text: String(result) }];
234
+ const nested = [];
235
+ let body = result;
236
+ if (toolResult && Array.isArray(result.results)) {
237
+ body = {
238
+ ...result,
239
+ results: result.results.map(item => {
240
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
241
+ const callId = String(item.id || "");
242
+ return peelCallImages(item, callId, nested);
243
+ }),
244
+ };
245
+ }
246
+ const top = toolResult ? imageEntries(body) : [];
247
+ if (!top.length && !nested.length) {
248
+ return [{ type: "text", text: JSON.stringify(body) }];
249
+ }
250
+ return [
251
+ { type: "text", text: JSON.stringify(top.length ? withoutImages(body) : body) },
252
+ ...top.map(image => imageBlock(image)),
253
+ ...nested,
254
+ ];
255
+ }
256
+
257
+ const DRIVE_TOOL_NAMES = ["qingzhou_guide", "qingzhou_create_drive_session", "qingzhou_list_drive_sessions", "qingzhou_get_drive_session", "qingzhou_stop_session", "qingzhou_execute_tool", "qingzhou_list_tools", "qingzhou_get_tool", "qingzhou_bridge_status"];
258
+ const GUIDE_TOOL = {
259
+ name: "qingzhou_guide",
260
+ description: "Return the qz CLI direct-drive guide as an optional reference.",
261
+ inputSchema: {
262
+ type: "object",
263
+ additionalProperties: false,
264
+ properties: {},
265
+ },
266
+ };
267
+ const DRIVE_INSTRUCTIONS = "先用 qingzhou_create_drive_session 或 qz session create --title TITLE 创建带可读标题的会话,后续每次执行工具或脚本都传入该 sessionId(CLI 必传 --session,缺失退出码为 2,不会自动建会话);list_tools/get_tool 查询基础目录无需会话。页面工具必传 tabRef(open_tab 设置的可选 tabAlias,或 open_tab / list_tabs 返回的 tabRef)。通过 qingzhou_execute_tool(单工具、calls 批次或 run_script 外层编排)执行操作,页面内 DOM 求值使用 evaluate。Create a session with a readable title using qingzhou_create_drive_session or qz session create --title TITLE, then pass that sessionId on every tool or script execution (CLI calls require --session; omitting it exits 2 and does not create a session). list_tools/get_tool can inspect the base catalog without a session. Page tools require tabRef (an optional tabAlias from open_tab, or the tabRef returned by open_tab or list_tabs). Orchestrate with qingzhou_execute_tool (single tool, calls batch, or outer run_script) and use evaluate for in-page DOM execution.";
268
+
269
+ export function mcpCatalogProfile({ clientName = "", profile = "" } = {}) {
270
+ const explicit = String(profile || "").trim().toLowerCase();
271
+ if (explicit === "full" || explicit === "drive") return explicit;
272
+ return clientName === "qz-cli" ? "full" : "drive";
273
+ }
274
+
275
+ export function driveToolCatalog() {
276
+ const byName = new Map(TOOL_CATALOG.map(tool => [tool.name, tool]));
277
+ return DRIVE_TOOL_NAMES.map(name => name === "qingzhou_guide" ? GUIDE_TOOL : byName.get(name));
170
278
  }
171
279
 
172
- export function createMcpServer(state) {
280
+ export function createMcpServer(state, { clientName = "", profile = "" } = {}) {
281
+ const catalogProfile = mcpCatalogProfile({ clientName, profile });
173
282
  const server = new Server({ name: "qz-browser-agent-mcp-bridge", version: PACKAGE_VERSION }, {
174
- capabilities: { tools: {} }, instructions: "Control qz tasks and browser tools through the local bridge.",
283
+ capabilities: { tools: {} },
284
+ instructions: catalogProfile === "drive" ? DRIVE_INSTRUCTIONS : "Control qz tasks and browser tools through the local bridge.",
175
285
  });
176
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_CATALOG }));
286
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
287
+ tools: catalogProfile === "drive" ? driveToolCatalog() : TOOL_CATALOG,
288
+ }));
177
289
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
178
290
  try {
179
291
  let progress = 0;
180
292
  const progressToken = request.params._meta?.progressToken;
181
- const result = await executeMcpTool(state, request.params.name, request.params.arguments || {}, { signal: extra.signal,
293
+ const input = { ...(request.params.arguments || {}) };
294
+ if (request.params.name === "qingzhou_guide") {
295
+ return { content: [{ type: "text", text: driveGuide() }] };
296
+ }
297
+ if (catalogProfile === "drive" && request.params.name === "qingzhou_list_sessions") {
298
+ if (!String(input.scope || "").trim()) input.scope = "session";
299
+ }
300
+ if (request.params.name === "qingzhou_execute_tool" && !String(input.sessionId || "").trim()) {
301
+ throw new BridgeError("DRIVE_SESSION_REQUIRED", "Pass sessionId from qz session create (MCP: qingzhou_create_drive_session). CLI calls require --session. This call does not create a session.");
302
+ }
303
+ const result = await executeMcpTool(state, request.params.name, input, { signal: extra.signal, clientName,
182
304
  onTaskUpdate: progressToken === undefined ? undefined : event => extra.sendNotification({
183
305
  method: "notifications/progress", params: { progressToken, progress: ++progress,
184
306
  message: ["text", "replace"].includes(event.type) ? event.text : JSON.stringify(event),
185
307
  _meta: { "qz/task-event": event } },
186
308
  }).catch(() => {}),
187
309
  });
188
- return { content: resultToContent(result) };
310
+ return { content: resultToContent(result, { toolResult: request.params.name === "qingzhou_execute_tool" }) };
189
311
  } catch (error) {
190
- const message = error.message || String(error);
191
- const value = typeof error.code === "string" && error.code ? {
192
- code: error.code, message,
193
- ...(error.details && Object.keys(error.details).length ? { details: error.details } : {}),
194
- } : message;
312
+ const value = { code: String(error.code || "TOOL_EXECUTION_FAILED").toUpperCase(), message: error.message || String(error),
313
+ ...(error.hint ? { hint: error.hint } : {}),
314
+ ...(error.details && Object.keys(error.details).length ? { details: error.details } : {}) };
195
315
  return { isError: true, content: resultToContent(value) };
196
316
  }
197
317
  });