qz-browser-agent 0.1.2 → 0.1.3

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 CHANGED
@@ -94,7 +94,7 @@ CLI and MCP share the same browser connection. The MCP tool list is in the
94
94
  | `qz send TASK_ID "MESSAGE"` | Send a follow-up, or `--steer` the current run |
95
95
  | `qz tasks` | List tasks |
96
96
  | `qz show TASK_ID` | Read a task's status, response, or pending question |
97
- | `qz watch TASK_ID` | Follow a running task until it finishes |
97
+ | `qz watch TASK_ID` | Follow a task until its work and queued messages finish |
98
98
  | `qz submit` / `approve` / `reject` | Answer a question or permission request |
99
99
  | `qz cancel TASK_ID` | Stop a task |
100
100
  | `qz data` | List, export, or delete data tables |
@@ -107,6 +107,12 @@ CLI and MCP share the same browser connection. The MCP tool list is in the
107
107
  With several browser profiles connected, choose one with `--client NAME|UUID` (or `-c`).
108
108
  Run `qz COMMAND --help` for options. Interactive terminals show readable text; pipes receive JSON.
109
109
 
110
+ `watch`, `--wait`, and `--stream` follow the same task across runs until its current work and
111
+ accepted follow-ups and steering messages have completed and its queues are empty. Questions
112
+ and permission requests keep observation open. Task failure or stopping ends observation.
113
+ Timeout or Ctrl-C ends observation while the browser task continues; execute the returned
114
+ `watch` command with the same task ID and browser UUID to resume. Observation never resends messages.
115
+
110
116
  ## Privacy and local access
111
117
 
112
118
  The service accepts connections only on your computer, and ordinary websites cannot use its MCP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qz-browser-agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local browser tools and MCP access for the qz extension",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=22" },
package/src/catalog.json CHANGED
@@ -62,10 +62,10 @@
62
62
  },
63
63
  {
64
64
  "name": "qingzhou_create_session",
65
- "description": "Create a task with an optional first message. Set wait=true to return its completed snapshot, or stream=true for progress events. User-input waits keep observation open. Cancellation stops observing; the browser task continues.",
65
+ "description": "Create a task with an optional first message. Set wait=true to follow the same task across runs until its current work and accepted follow-ups and steering messages have completed and its queues are empty. stream=true adds progress events. User-input waits keep observation open. Task failure or stopping ends observation. Timeout or cancellation ends observation while the browser task continues; use the returned watch recovery call without resubmitting.",
66
66
  "inputSchema": {
67
67
  "properties": {
68
- "wait": {"type": "boolean", "default": false, "description": "Keep observing until the task finishes, including user-input waits."},
68
+ "wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
69
69
  "stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
70
70
  "waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
71
71
  "title": {"default": "", "title": "Title", "type": "string"},
@@ -80,10 +80,10 @@
80
80
  },
81
81
  {
82
82
  "name": "qingzhou_send_message",
83
- "description": "Send a message to the specified task. By default, a busy task queues a follow-up for its next run. Set steer=true to guide its current run at the next safe boundary. An idle task starts with either mode. Returns deliveryMode, disposition, and clientMessageId for tracking. Set wait=true to observe the submitted message through completion, including queued follow-ups and user-input waits. stream=true also emits text through MCP progress notifications.",
83
+ "description": "Send a message to the specified task. By default, a busy task queues a follow-up for its next run. Set steer=true to guide its current run at the next safe boundary. An idle task starts with either mode. Returns deliveryMode, disposition, and clientMessageId to confirm acceptance. Set wait=true to follow the same task across runs until its work completes and its follow-up and steering queues are empty. A queued message removed before execution no longer blocks completion. stream=true emits text from the submitted message onward through MCP progress notifications. Timeout or cancellation ends observation; use the returned watch recovery call without resubmitting.",
84
84
  "inputSchema": {
85
85
  "properties": {
86
- "wait": {"type": "boolean", "default": false, "description": "Keep observing until the task finishes, including user-input waits."},
86
+ "wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
87
87
  "stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
88
88
  "waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
89
89
  "text": {"title": "Text", "type": "string"},
@@ -150,7 +150,7 @@
150
150
  "description": "Respond using the pending input's responseTemplate: fill answers keyed by field IDs with option values for choices (arrays for multi_choice), then submit; approve a completed action or application consent, or reject. Show location disclosures and obtain explicit user consent before approving with consentVersion. Native Chrome permissions require the user's click in qz. Invalid or stale replies return the current input for correction and keep the request pending.",
151
151
  "inputSchema": {
152
152
  "properties": {
153
- "wait": {"type": "boolean", "default": false, "description": "Keep observing until the task finishes, including user-input waits."},
153
+ "wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
154
154
  "stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
155
155
  "waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
156
156
  "sessionId": {"type": "string", "minLength": 1},
@@ -357,7 +357,7 @@
357
357
  },
358
358
  {
359
359
  "name": "qingzhou_watch_task",
360
- "description": "Observe an existing task until it finishes without resubmitting it. Questions and Chrome permission requests keep observation open; MCP progress notifications include their full input and responseTemplate. stream=true includes text updates. Cancellation stops observing while the task continues.",
360
+ "description": "Observe an existing task across runs until its current work and accepted follow-ups and steering messages have completed and its queues are empty, without resubmitting anything. Questions and Chrome permission requests keep observation open; MCP progress notifications include their full input and responseTemplate. stream=true includes text updates. Task failure or stopping ends observation. Timeout or cancellation ends observation while the browser task continues; resume with the returned recovery call using the same sessionId and clientId.",
361
361
  "inputSchema": {
362
362
  "type": "object",
363
363
  "properties": {
package/src/cli.mjs CHANGED
@@ -213,14 +213,14 @@ async function dispatchCommand(parsed, dependencies) {
213
213
  throw error;
214
214
  }
215
215
  };
216
- const observe = async (sessionId, { runId = "", expectedMessageId = "", previousRunId = "", ignoreWaitId = "", allowIdle = false } = {}) => {
216
+ const observe = async (sessionId, { runId = "", expectedMessageId = "", messageWasQueued = false, ignoreWaitId = "", allowIdle = false } = {}) => {
217
217
  try {
218
218
  const remaining = waitTimeoutSeconds == null ? null : waitTimeoutSeconds - (Date.now() - startedAt) / 1000;
219
219
  if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after the configured --timeout. The browser task may still be running.", "TimeoutError");
220
220
  return await waitForTask({
221
221
  sessionId, clientId: common.clientId, timeoutSeconds: remaining, signal: dependencies.signal,
222
222
  requestTimeoutSeconds: timeoutSeconds,
223
- expectedRunId: runId, expectedMessageId, previousRunId, ignoreWaitId, allowIdle,
223
+ expectedRunId: runId, expectedMessageId, messageWasQueued, ignoreWaitId, allowIdle,
224
224
  onUpdate: dependencies.onTaskUpdate,
225
225
  getSnapshot: ({ timeoutSeconds: seconds, signal }) => readSnapshot(sessionId, seconds, signal),
226
226
  });
@@ -245,7 +245,9 @@ async function dispatchCommand(parsed, dependencies) {
245
245
  sessionId: id, hint: "Continue watching the same task:", command: recoveryCommand("watch", id, clientId, true),
246
246
  });
247
247
  }
248
- result = { ...result, snapshot: await observe(id, { expectedMessageId: result.clientMessageId }) };
248
+ result = { ...result, snapshot: await observe(id, {
249
+ expectedMessageId: result.clientMessageId, messageWasQueued: result.disposition === "queued",
250
+ }) };
249
251
  }
250
252
  return { result };
251
253
  }
package/src/commands.mjs CHANGED
@@ -14,7 +14,7 @@ export const COMMANDS = {
14
14
  create: { options: [...waiting, "title", "parent"], summary: "Start a task",
15
15
  help: ['qz create "PROMPT" [--title TITLE] [--wait | --stream]', "Start a browser task. The title is optional.",
16
16
  "--title, -t Set the task title. --parent TASK_ID creates a child task.",
17
- "--wait Wait for completion, including pauses for user input.", "--stream Show responses as they arrive; also wait.",
17
+ "--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
18
  "Use - as the prompt to read stdin.",
19
19
  'qz create "Compare these two pages." --stream', 'qz create "Summarize this page." --title Research --wait'],
20
20
  },
@@ -22,7 +22,8 @@ export const COMMANDS = {
22
22
  help: ['qz send TASK_ID "MESSAGE" [--steer] [--wait | --stream]', "Send a follow-up or steer a specific task.",
23
23
  "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
24
  "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 submitted message; --stream also shows its responses.",
25
+ "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.",
26
+ "--stream also shows responses from this message onward, including later runs.",
26
27
  'qz send TASK_ID "Add the source links." --stream', 'qz send TASK_ID "Focus on open-source options." --steer --stream'],
27
28
  },
28
29
  tasks: { options: [...request, "limit"], summary: "List tasks",
@@ -32,19 +33,20 @@ export const COMMANDS = {
32
33
  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
34
  },
34
35
  watch: { options: [...request, "stream"], summary: "Wait for a task",
35
- help: ["qz watch TASK_ID [--stream]", "Wait for an existing task to finish, including pauses for user input.", "--stream Show assistant responses as they arrive.", "Timeout or Ctrl-C ends watching without cancelling the task."],
36
+ 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
37
  },
37
38
  submit: { options: [...waiting, "wait-id"], summary: "Answer a form",
38
39
  help: ["qz submit TASK_ID JSON [--wait | --stream]", "Submit answers to the current form. Use - to read JSON from stdin.",
39
40
  "--wait-id ID Bind the response to a previously read wait. Otherwise qz reads the current wait before submitting.",
41
+ "--wait and --stream follow the task across runs until its work completes and accepted message queues are empty.",
40
42
  'qz submit TASK_ID \'{"selectedIds":["option-a"]}\' --stream'],
41
43
  },
42
44
  approve: { options: [...waiting, "wait-id"], summary: "Continue a waiting task",
43
45
  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."],
46
+ "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
47
  },
46
48
  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 optionally follow the task's next step. Use qz cancel TASK_ID to stop the whole task."],
49
+ 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
50
  },
49
51
  cancel: { options: request, summary: "Stop a task",
50
52
  help: ["qz cancel TASK_ID", "Stop a task's active run.", "qz stop stops the local service instead."],
@@ -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");
@@ -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 => {
@@ -137,67 +143,63 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
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/tools.mjs CHANGED
@@ -78,7 +78,7 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
78
78
  if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after waitTimeoutSeconds. The browser task may still be running.", "TimeoutError");
79
79
  return await waitForTask({ sessionId, clientId: args.clientId, signal, timeoutSeconds: remaining,
80
80
  requestTimeoutSeconds: args.timeoutSeconds || state.requestTimeoutSeconds || 180,
81
- expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId,
81
+ expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId, messageWasQueued: tracking.disposition === "queued",
82
82
  ignoreWaitId: tracking.ignoreWaitId, allowIdle: tracking.allowIdle,
83
83
  onUpdate: onTaskUpdate && (event => args.stream || !["text", "replace"].includes(event.type) ? onTaskUpdate(event) : undefined),
84
84
  getSnapshot: async ({ signal: requestSignal, timeoutSeconds }) => bindInputClient("qingzhou_get_session_snapshot",
@@ -140,9 +140,11 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
140
140
  result = { ...result, snapshot: await watch(sessionId, { allowIdle: !args.firstMessage.trim() }) };
141
141
  } else if (name === "qingzhou_send_message" && result?.accepted === true) {
142
142
  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.");
143
+ throw Object.assign(new BridgeError("invalid_response", "The message was accepted without a tracking ID. Continue watching the same task before sending it again."), {
144
+ details: { sessionId, clientId, recovery: { tool: "qingzhou_watch_task", arguments: { sessionId, clientId } } },
145
+ });
144
146
  }
145
- result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId }) };
147
+ result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId, disposition: result.disposition }) };
146
148
  } else if (name === "qingzhou_respond_to_user_input" && (result?.accepted === true || result?.requiresUserAction)) {
147
149
  const snapshot = await watch(sessionId, { runId: result.runId || "", ignoreWaitId: result.requiresUserAction ? "" : args.waitId });
148
150
  result = result.requiresUserAction ? { sessionId, waitId: args.waitId, snapshot } : { ...result, snapshot };