browserzap 0.6.1 → 0.8.0

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.
@@ -96,10 +96,14 @@ function componentOf(c) {
96
96
  };
97
97
  }
98
98
 
99
- function pageOf(p) {
99
+ function pageOf(p, { optional = false } = {}) {
100
100
  const page = p && typeof p === 'object' ? p : {};
101
101
  const url = str(page.url, 2000);
102
- if (!url || !/^https?:\/\//i.test(url)) throw new FeedbackError('Every comment needs the http(s) page it was made on.');
102
+ if (!url || !/^https?:\/\//i.test(url)) {
103
+ // A message typed in the side panel may come from a tab with no web page (chrome://, a new tab).
104
+ if (optional) return null;
105
+ throw new FeedbackError('Every comment needs the http(s) page it was made on.');
106
+ }
103
107
  return {
104
108
  url,
105
109
  title: str(page.title, 200),
@@ -140,11 +144,14 @@ function saveBatch(batch, { now = new Date() } = {}) {
140
144
  const n = Number.isInteger(c.n) && c.n > 0 && c.n < 1000 ? c.n : index + 1;
141
145
  const shot = imageData(c.screenshot, `The screenshot of comment ${n}`);
142
146
  const attachments = Array.isArray(c.attachments) ? c.attachments.slice(0, MAX_ATTACHMENTS) : [];
147
+ // kind "message": typed in the side panel's message box, not pinned to an element.
148
+ const message = c.kind === 'message';
143
149
  const entry = {
144
150
  n,
151
+ kind: message ? 'message' : 'comment',
145
152
  text,
146
- page: pageOf(c.page),
147
- element: elementOf(c.info),
153
+ page: pageOf(c.page, { optional: message }),
154
+ element: message ? null : elementOf(c.info),
148
155
  component: componentOf(c.component),
149
156
  screenshot: null,
150
157
  attachments: [],
@@ -167,7 +174,11 @@ function saveBatch(batch, { now = new Date() } = {}) {
167
174
 
168
175
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
169
176
  for (const [name, data] of files) writePrivate(path.join(dir, name), data);
170
- const record = { id, createdAt: now.toISOString(), delivered: false, deliveredAt: null, comments: stored };
177
+ // The agent session this batch is for (the owner of the panel's tab); null means any agent may read it.
178
+ const session = typeof batch.session === 'string' && /^[\w-]{1,64}$/.test(batch.session) ? batch.session : null;
179
+ // The tab the person wrote from: whoever reads the batch answers there, even when no agent owned the tab.
180
+ const tabId = Number.isInteger(batch.tabId) && batch.tabId >= 0 ? batch.tabId : null;
181
+ const record = { id, createdAt: now.toISOString(), session, tabId, delivered: false, deliveredAt: null, comments: stored };
171
182
  writePrivate(path.join(dir, 'batch.json'), JSON.stringify(record, null, 2));
172
183
  prune({ now: now.getTime() });
173
184
  return { id, dir, count: stored.length };
@@ -190,25 +201,32 @@ function batchIds() {
190
201
  }
191
202
  }
192
203
 
193
- /** Newest first: { id, createdAt, count, pages, delivered }. */
194
- function list({ includeDelivered = false } = {}) {
195
- return batchIds().reverse()
204
+ /** A batch addressed to another agent session is not this session's to read. */
205
+ function forSession(batch, session) {
206
+ return !session || !batch.session || batch.session === session;
207
+ }
208
+
209
+ /** Newest first (oldest first with `oldest`): { id, createdAt, count, pages, delivered, session }. */
210
+ function list({ includeDelivered = false, session = null, oldest = false } = {}) {
211
+ const ids = batchIds();
212
+ return (oldest ? ids : ids.reverse())
196
213
  .map((id) => readBatch(id))
197
- .filter((b) => b && (includeDelivered || !b.delivered))
214
+ .filter((b) => b && (includeDelivered || !b.delivered) && forSession(b, session))
198
215
  .map((b) => ({
199
216
  id: b.id,
200
217
  createdAt: b.createdAt,
201
218
  count: b.comments.length,
202
- pages: [...new Set(b.comments.map((c) => c.page.url))].slice(0, 5),
219
+ pages: [...new Set(b.comments.map((c) => c.page?.url).filter(Boolean))].slice(0, 5),
203
220
  delivered: b.delivered,
221
+ session: b.session ?? null,
204
222
  }));
205
223
  }
206
224
 
207
- /** One batch with absolute file paths, or the newest one not yet delivered. */
208
- function get(id) {
209
- const chosen = id ?? list()[0]?.id;
225
+ /** One batch with absolute file paths, or the newest (`oldest`: oldest) one not yet delivered to this session. */
226
+ function get(id, { session = null, oldest = false } = {}) {
227
+ const chosen = id ?? list({ session, oldest })[0]?.id;
210
228
  const batch = chosen ? readBatch(chosen) : null;
211
- if (!batch) return null;
229
+ if (!batch || !forSession(batch, session)) return null;
212
230
  const dir = path.join(feedbackDir(), batch.id);
213
231
  return {
214
232
  ...batch,
@@ -221,6 +239,26 @@ function get(id) {
221
239
  };
222
240
  }
223
241
 
242
+ /**
243
+ * A message addressed to an agent that has gone must not stay unreadable. Every
244
+ * undelivered batch stamped with a session that is not live loses the stamp
245
+ * (null = any agent may read it). Called when an agent session ends, and once at
246
+ * bridge start, which sweeps whatever a killed bridge left behind.
247
+ */
248
+ function releaseOrphans({ session = null, liveSessionIds = new Set() } = {}) {
249
+ let released = 0;
250
+ for (const id of batchIds()) {
251
+ const batch = readBatch(id);
252
+ if (!batch || batch.delivered || !batch.session) continue;
253
+ if (session && batch.session !== session) continue;
254
+ if (liveSessionIds.has(batch.session)) continue;
255
+ batch.session = null;
256
+ writePrivate(path.join(feedbackDir(), id, 'batch.json'), JSON.stringify(batch, null, 2));
257
+ released++;
258
+ }
259
+ return released;
260
+ }
261
+
224
262
  function markDelivered(id, { now = new Date() } = {}) {
225
263
  const batch = readBatch(id);
226
264
  if (!batch || batch.delivered) return false;
@@ -230,8 +268,8 @@ function markDelivered(id, { now = new Date() } = {}) {
230
268
  return true;
231
269
  }
232
270
 
233
- function pendingCount() {
234
- return list().length;
271
+ function pendingCount({ session = null } = {}) {
272
+ return list({ session }).length;
235
273
  }
236
274
 
237
275
  /** Delete batches older than the retention, and the oldest beyond MAX_BATCHES. Never throws. */
@@ -252,4 +290,4 @@ function prune({ now = Date.now(), days = keepDays() } = {}) {
252
290
  return removed;
253
291
  }
254
292
 
255
- module.exports = { saveBatch, list, get, markDelivered, pendingCount, prune, feedbackDir, FeedbackError, MAX_COMMENTS };
293
+ module.exports = { saveBatch, list, get, markDelivered, pendingCount, releaseOrphans, prune, feedbackDir, FeedbackError, MAX_COMMENTS };
@@ -4,9 +4,10 @@
4
4
  * BrowserZap — entry point.
5
5
  *
6
6
  * When used as an MCP server (the primary use case), this starts the MCP
7
- * server, which embeds the WebSocket bridge. The Chrome extension is installed
8
- * separately; pass --extension to plant a copy under the user's home directory
9
- * and print the path for a "Load unpacked" install.
7
+ * server, which embeds the WebSocket bridge. The Chrome extension comes from
8
+ * the Chrome Web Store and updates itself; a developer working from a source
9
+ * checkout can pass --extension to copy the built build under the user's home
10
+ * directory and print the path for a "Load unpacked" install.
10
11
  *
11
12
  * Derived from Open Browser Control (MIT, Copyright (c) 2026 smankoo) at commit
12
13
  * 4e6c262. BrowserZap drops the Firefox target and rebrands.
@@ -17,7 +18,7 @@
17
18
  * Standalone:
18
19
  * node bridge-server/index.js # MCP mode (default)
19
20
  * node bridge-server/index.js --bridge # standalone WebSocket bridge
20
- * node bridge-server/index.js --extension # install + print the extension path
21
+ * node bridge-server/index.js --extension # copy + print the built extension (developer)
21
22
  * node bridge-server/index.js --uninstall # remove the installed extension copy
22
23
  * node bridge-server/index.js --print-token # print the pairing token
23
24
  */
@@ -28,6 +29,7 @@ const os = require('os');
28
29
 
29
30
  const { getOrCreateSecret, secretPath, configDir } = require('./secret');
30
31
  const { DEFAULT_PORT } = require('./server');
32
+ const { STORE_URL } = require('./setup');
31
33
 
32
34
  const args = process.argv.slice(2);
33
35
 
@@ -82,18 +84,29 @@ function syncDir(src, dest) {
82
84
  prune(dest, dest);
83
85
  }
84
86
 
87
+ /**
88
+ * The extension build inside this package, or null.
89
+ *
90
+ * npm ships the bridge only (D3), so an installed copy has neither folder and
91
+ * nothing to copy. A source checkout has extension/ after `npm run build`, or
92
+ * dist/chrome when webpack output is all that exists yet.
93
+ */
94
+ function builtExtensionDir() {
95
+ for (const candidate of [path.join(__dirname, '..', 'extension'), path.join(__dirname, '..', 'dist', 'chrome')]) {
96
+ if (fs.existsSync(path.join(candidate, 'manifest.json'))) return candidate;
97
+ }
98
+ return null;
99
+ }
100
+
85
101
  function installExtension({ quiet = false } = {}) {
86
102
  const say = quiet ? () => {} : log;
87
- const bundled = path.join(__dirname, '..', 'extension');
88
-
89
- if (!fs.existsSync(path.join(bundled, 'manifest.json'))) {
90
- // Dev mode — fall back to the raw webpack output.
91
- const devDist = path.join(__dirname, '..', 'dist', 'chrome');
92
- if (fs.existsSync(path.join(devDist, 'manifest.json'))) {
93
- syncDir(devDist, EXTENSION_HOME);
94
- return true;
95
- }
96
- say('Pre-built extension not found. Run "npm run build" first.');
103
+ const bundled = builtExtensionDir();
104
+
105
+ if (!bundled) {
106
+ // The store is the install path for a user; this flag is a developer tool.
107
+ process.stdout.write('The extension is not part of the npm package. Install it from the Chrome Web Store:\n');
108
+ process.stdout.write(` ${STORE_URL}\n`);
109
+ process.stdout.write('(Chrome keeps it updated. "browserzap --extension" only works from a source checkout.)\n');
97
110
  return false;
98
111
  }
99
112
 
@@ -119,8 +132,9 @@ function installExtension({ quiet = false } = {}) {
119
132
  say(` 4. Select: ${EXTENSION_HOME}`);
120
133
  say('');
121
134
  say('One-time setup (registers the native messaging host, so the extension');
122
- say('connects automatically with no token to paste):');
123
- say(' browserzap setup');
135
+ say('connects automatically with no token to paste; --dev lets this unpacked');
136
+ say('build in, which the store build does not need):');
137
+ say(' browserzap setup --dev');
124
138
  say('');
125
139
  return true;
126
140
  }
@@ -277,13 +291,10 @@ if (require('./setup').insideWsl() && !process.env.BROWSERZAP_ALLOW_LINUX) {
277
291
  const SUBCOMMANDS = new Set(['setup', 'doctor', 'uninstall', 'run', 'restart-bridge']);
278
292
 
279
293
  if (command === 'setup') {
280
- // From an installed package, copy the extension to a folder that outlives an
281
- // upgrade or an npm cache clean, so "Load unpacked" names a path that stays
282
- // valid. A source checkout loads its own extension/ folder.
283
- const checkout = fs.existsSync(path.join(__dirname, '..', 'src', 'manifest.json'));
284
- const extensionPath = checkout
285
- ? path.join(__dirname, '..', 'extension')
286
- : installExtension({ quiet: true }) ? EXTENSION_HOME : null;
294
+ // A source checkout has a built extension to load unpacked; an npm install has
295
+ // none, and the store build is the extension there (D3). Nothing is copied:
296
+ // setup only names the path a developer already has.
297
+ const extensionPath = builtExtensionDir();
287
298
  require('./setup').setupCommand(args.slice(1), { extensionPath })
288
299
  .then((code) => process.exit(code))
289
300
  .catch((err) => {
@@ -493,6 +504,7 @@ browserzap — let an AI agent act on the browser tab you already have open.
493
504
  Usage:
494
505
  browserzap setup One-time setup: register the native messaging host and
495
506
  optionally add the MCP server to detected clients
507
+ browserzap setup --dev Allow the unpacked developer build to connect (store-only otherwise)
496
508
  browserzap doctor Check the host manifest, registry, extension id and bridge
497
509
  browserzap restart-bridge Replace a bridge that will not pair (another secret or version)
498
510
  browserzap uninstall [--purge] Remove the host registration and installed extension copy
@@ -501,7 +513,7 @@ Usage:
501
513
  more than one). Exit code 0 when every run completed
502
514
  browserzap Start the MCP server (default)
503
515
  browserzap --bridge Start the standalone WebSocket bridge
504
- browserzap --extension Install + print the extension path
516
+ browserzap --extension Developer: copy the built extension to the install folder (source checkout only)
505
517
  browserzap --uninstall Remove the installed extension copy (keeps your data)
506
518
  browserzap --uninstall --purge
507
519
  Also remove the config folder (pairing token,
@@ -22,6 +22,13 @@ const LIMITS = Object.freeze({
22
22
  APPROVAL_HOLD_MS: 20000,
23
23
  /** A click or key press without `expect` watches this long for the page to react. */
24
24
  DEFAULT_SETTLE_MS: 1500,
25
+ /**
26
+ * After a click started a request, how long to keep watching for a navigation
27
+ * once the page has settled. A form POST in a background tab can navigate a
28
+ * moment after the visible change, and the evidence must not say `navigated:
29
+ * false` (B-02). A bounded poll, never a fixed sleep.
30
+ */
31
+ NAVIGATION_FOLLOW_MS: 1200,
25
32
  /** Before acting, the page must have had no tracked request in flight for this long. */
26
33
  SETTLE_QUIET_MS: 500,
27
34
  /** How long that settle waits for a busy page (it costs nothing on a quiet one). */
@@ -36,7 +43,7 @@ const LIMITS = Object.freeze({
36
43
  /** Longest per-step pacing a person may set on a saved workflow step. */
37
44
  MAX_STEP_PACING_MS: 30000,
38
45
  /** A tab with no agent action for this long is detached, so Chrome's "debugging" bar goes away (D-35). */
39
- DEBUGGER_IDLE_MS: 60000,
46
+ DEBUGGER_IDLE_MS: 300000,
40
47
  /** Tabs a session may have while it works (main + helpers); one more closes the least recently used helper (D-38). */
41
48
  MAX_SESSION_TABS: 5,
42
49
  /** Tabs a session keeps once it has been idle for TAB_TRIM_IDLE_MS. */
@@ -93,7 +100,8 @@ function callDeadlineMs(tool, args = {}) {
93
100
  case 'element_click':
94
101
  // find the target, maybe hold for an approval, then watch the result
95
102
  waits = (asked ?? LIMITS.DEFAULT_TARGET_WAIT_MS) + LIMITS.APPROVAL_HOLD_MS
96
- + (asked ?? (args?.expect ? LIMITS.DEFAULT_EXPECT_TIMEOUT_MS : LIMITS.DEFAULT_SETTLE_MS));
103
+ + (asked ?? (args?.expect ? LIMITS.DEFAULT_EXPECT_TIMEOUT_MS : LIMITS.DEFAULT_SETTLE_MS))
104
+ + LIMITS.NAVIGATION_FOLLOW_MS;
97
105
  break;
98
106
  case 'browser_press_key':
99
107
  waits = LIMITS.APPROVAL_HOLD_MS + LIMITS.DEFAULT_SETTLE_MS;
@@ -114,9 +122,20 @@ function callDeadlineMs(tool, args = {}) {
114
122
  return Math.max(CALL_DEADLINE_MS, waits + DEADLINE_MARGIN_MS);
115
123
  }
116
124
 
125
+ /**
126
+ * feedback_wait (the chat listener): how long one call waits for the person by default and at most, and how often
127
+ * it looks. A bounded poll of the local feedback folder (ADR-007); progress keeps the client from timing out.
128
+ */
129
+ const FEEDBACK_WAIT_DEFAULT_MS = 60_000;
130
+ const FEEDBACK_WAIT_MAX_MS = 300_000;
131
+ const FEEDBACK_POLL_MS = 500;
132
+
117
133
  module.exports = {
118
134
  LIMITS,
119
135
  CALL_DEADLINE_MS,
136
+ FEEDBACK_WAIT_DEFAULT_MS,
137
+ FEEDBACK_WAIT_MAX_MS,
138
+ FEEDBACK_POLL_MS,
120
139
  STATUS_DEADLINE_MS,
121
140
  RUN_DEADLINE_MS,
122
141
  MAX_STEP_RESOLVE_MS,
@@ -30,8 +30,9 @@ const path = require('path');
30
30
  const { getOrCreateSecret, configDir } = require('./secret');
31
31
  const { BIND_HOST, DEFAULT_PORT, PROTOCOL_VERSION, sanitizeSessionName } = require('./server');
32
32
  const { ensureBridge: ensureBridgeListening } = require('./ensure-bridge');
33
- const { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS } = require('./limits');
33
+ const { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS, FEEDBACK_WAIT_DEFAULT_MS, FEEDBACK_WAIT_MAX_MS, FEEDBACK_POLL_MS } = require('./limits');
34
34
  const chromeLauncher = require('./chrome-launcher');
35
+ const { MIN_EXTENSION_VERSION, isOlderThan } = require('./extension-version');
35
36
  const { MCP_TOOLS, LOCAL_TOOLS, validateArguments } = require('./tools');
36
37
  const { currentPlan, hasFeature } = require('./entitlements');
37
38
  const { envelope, errorEnvelope, executeStep, createActionClient } = require('./executor');
@@ -160,6 +161,12 @@ function connectToBridge() {
160
161
  }
161
162
  return;
162
163
  }
164
+ // The person sent a message or notes from the side panel: a waiting feedback_wait reads it now (feedbackWait).
165
+ if (msg.type === 'event' && msg.event === 'feedback_new') {
166
+ if (feedbackWaiters.size) log('feedback_nudged', { waiting: feedbackWaiters.size });
167
+ feedbackWaiters.forEach((wake) => wake());
168
+ return;
169
+ }
163
170
  // The person pressed Stop on the page (or took control in the side panel):
164
171
  // a background batch stops with the step in flight.
165
172
  if (msg.type === 'event' && msg.event === 'user_handoff') {
@@ -223,6 +230,26 @@ function updateSessionName(name) {
223
230
  }
224
231
  }
225
232
 
233
+ /** Running feedback_wait calls, woken by the bridge's feedback_new event; the folder poll stays as the fallback. */
234
+ const feedbackWaiters = new Set();
235
+
236
+ /** The side panel shows "listening" while feedback_wait runs. */
237
+ function updateListening(listening) {
238
+ if (sessionStarted && ws?.readyState === WebSocket.OPEN) {
239
+ ws.send(JSON.stringify({ type: 'session_update', session: SESSION_ID, listening }));
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The tab the last batch came from: the extension shows this session's replies there. Needed when the person
245
+ * writes from a tab no agent owned — the batch is unaddressed, but the answer still belongs in that tab.
246
+ */
247
+ function updateChatTab(tabId) {
248
+ if (sessionStarted && ws?.readyState === WebSocket.OPEN && Number.isInteger(tabId) && tabId >= 0) {
249
+ ws.send(JSON.stringify({ type: 'session_update', session: SESSION_ID, chatTabId: tabId }));
250
+ }
251
+ }
252
+
226
253
  /** Requests answered by the bridge itself (Chrome profiles), matched by id. */
227
254
  const bridgeReplies = new Map();
228
255
  let bridgeSeq = 0;
@@ -482,6 +509,12 @@ async function callToolWith(progress, requestId, toolName, args) {
482
509
  const started = Date.now();
483
510
  const traceId = `${SESSION_ID}-t${++traceSeq}`;
484
511
  const finish = (env, extraContent) => {
512
+ // The person can message the agent from the side panel at any time; the
513
+ // agent learns of it on its next call. browser_status says it itself.
514
+ if (toolName !== 'browser_status' && !toolName.startsWith('feedback_') && typeof env.summary === 'string') {
515
+ const waiting = feedback.pendingCount({ session: SESSION_ID });
516
+ if (waiting) env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
517
+ }
485
518
  log('tool_call', {
486
519
  tool: toolName,
487
520
  traceId: env.traceId,
@@ -519,6 +552,11 @@ async function callToolWith(progress, requestId, toolName, args) {
519
552
  return finish(env, extra);
520
553
  }
521
554
 
555
+ if (toolName === 'feedback_wait') {
556
+ const { env, extra } = await feedbackWait(requestId, traceId, args, progress);
557
+ return finish(env, extra);
558
+ }
559
+
522
560
  if (LOCAL_TOOLS.has(toolName)) return finish(await workflowTool(requestId, traceId, toolName, args, progress));
523
561
  // A background run is driving this session's tab: reading is fine, acting would interleave with its steps.
524
562
  const running = backgroundRuns.active();
@@ -557,6 +595,24 @@ async function callToolWith(progress, requestId, toolName, args) {
557
595
  }
558
596
  }
559
597
 
598
+ /**
599
+ * An action the connected build does not know (an extension older than the tool
600
+ * surface, src/actions/browser-actions.ts) comes back as `Unknown action: …`
601
+ * with INVALID_ARGUMENT. Report that as `needs_user` naming the build, so the
602
+ * person is told to update instead of the agent retrying a call that cannot
603
+ * work. Same code, same `ok`, the call itself is not refused here.
604
+ */
605
+ async function nameUnsupportedAction(env) {
606
+ if (env.ok !== false || env.error?.code !== 'INVALID_ARGUMENT') return;
607
+ if (typeof env.error.message !== 'string' || !env.error.message.startsWith('Unknown action:')) return;
608
+ const version = await connectedExtensionVersion();
609
+ env.state = 'needs_user';
610
+ env.summary = version
611
+ ? `The BrowserZap extension (${version}) does not support this action; update it from chrome://extensions → Update.`
612
+ : 'The BrowserZap extension does not support this action and did not report its version, which means it is older than 0.7.0; update it from chrome://extensions → Update.';
613
+ env.nextAction = 'Ask the user to update the BrowserZap extension. Chrome updates store extensions by itself; to update now open chrome://extensions → Update.';
614
+ }
615
+
560
616
  /**
561
617
  * Run one browser tool through the extension and build its envelope. Live
562
618
  * calls and workflow steps both come through here, so a replayed step gets
@@ -565,6 +621,7 @@ async function callToolWith(progress, requestId, toolName, args) {
565
621
  */
566
622
  async function executeTool(toolName, args, { allowedOrigins = ALLOWED_ORIGINS, onStart } = {}) {
567
623
  const env = await executeStep(startAction, toolName, args, { allowedOrigins, onStart });
624
+ await nameUnsupportedAction(env);
568
625
  const extra = [];
569
626
  if (typeof env.screenshot === 'string') {
570
627
  const format = env.format || 'jpeg';
@@ -677,14 +734,19 @@ function checkUpload(traceId, args) {
677
734
 
678
735
  // ─── Comment mode batches (docs/COMMENT_MODE.md) ─────────────────────────────
679
736
 
737
+ /** The sentence that tells the agent the person is waiting for it to read something. */
738
+ function unreadFeedbackNote(waiting) {
739
+ return `The person sent ${waiting} comment batch(es) or message(s) from the BrowserZap side panel: call feedback_get now and act on them.`;
740
+ }
741
+
680
742
  /** Inline images per feedback_get: enough to see every comment, small enough for any client. */
681
743
  const FEEDBACK_INLINE_IMAGES = 12;
682
744
  const FEEDBACK_INLINE_BYTES = 6 * 1024 * 1024;
683
745
  const MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
684
746
 
685
- function feedbackTool(traceId, toolName, args) {
747
+ function feedbackTool(traceId, toolName, args, options = {}) {
686
748
  if (toolName === 'feedback_list') {
687
- const batches = feedback.list({ includeDelivered: args.includeDelivered === true });
749
+ const batches = feedback.list({ includeDelivered: args.includeDelivered === true, session: SESSION_ID });
688
750
  const pending = batches.filter((b) => !b.delivered).length;
689
751
  return {
690
752
  env: okEnvelope(traceId, batches.length
@@ -693,7 +755,7 @@ function feedbackTool(traceId, toolName, args) {
693
755
  extra: [],
694
756
  };
695
757
  }
696
- const batch = feedback.get(args.id);
758
+ const batch = feedback.get(args.id, { session: SESSION_ID, oldest: options.oldest === true });
697
759
  if (!batch) {
698
760
  return {
699
761
  env: args.id
@@ -703,6 +765,8 @@ function feedbackTool(traceId, toolName, args) {
703
765
  };
704
766
  }
705
767
  if (args.markDelivered !== false) feedback.markDelivered(batch.id);
768
+ // Whoever read it answers in the tab it was written from (a reply with no tab would show in no panel at all).
769
+ if (Number.isInteger(batch.tabId)) updateChatTab(batch.tabId);
706
770
  const extra = [];
707
771
  let bytes = 0;
708
772
  if (args.images !== false) {
@@ -719,14 +783,58 @@ function feedbackTool(traceId, toolName, args) {
719
783
  }
720
784
  }
721
785
  }
722
- const pages = [...new Set(batch.comments.map((c) => c.page.url))];
723
- return {
724
- env: okEnvelope(traceId,
725
- `${batch.comments.length} comment(s) from the person on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}.`,
726
- { batch },
727
- {}),
728
- extra,
729
- };
786
+ const pages = [...new Set(batch.comments.map((c) => c.page?.url).filter(Boolean))];
787
+ const messages = batch.comments.filter((c) => c.kind === 'message').length;
788
+ const pinned = batch.comments.length - messages;
789
+ const what = [messages ? `${messages} message(s)` : '', pinned ? `${pinned} comment(s)` : ''].filter(Boolean).join(' and ');
790
+ const where = pages.length === 0 ? '' : ` on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}`;
791
+ const env = okEnvelope(traceId, `${what} from the person${where}.`, { batch }, {});
792
+ // The person reads the side panel, not the agent's terminal: an answer written anywhere else never reaches them.
793
+ if (messages) {
794
+ env.nextAction = 'The person is waiting in the BrowserZap side panel: put your answer there with chat_reply (Markdown is '
795
+ + 'shown). Text you write in your own chat is not shown to them.';
796
+ }
797
+ return { env, extra };
798
+ }
799
+
800
+ /**
801
+ * feedback_wait, the chat listener: returns the person's next message or note batch for this session (oldest
802
+ * first, like feedback_get), or says nothing came within waitMs. A bounded poll of the local folder; the panel
803
+ * shows "listening" meanwhile; a cancelled request stops at once.
804
+ */
805
+ async function feedbackWait(requestId, traceId, args, progress) {
806
+ const waitMs = Math.min(Math.max(Number.isInteger(args.waitMs) ? args.waitMs : FEEDBACK_WAIT_DEFAULT_MS, 1000), FEEDBACK_WAIT_MAX_MS);
807
+ const deadline = Date.now() + waitMs;
808
+ let cancelled = false;
809
+ let wake = null;
810
+ inflightByRequest.set(requestId, { cancel() { cancelled = true; wake?.(); } });
811
+ const nudge = () => wake?.();
812
+ feedbackWaiters.add(nudge);
813
+ updateListening(true);
814
+ progress.heartbeat(() => 'Listening for the person in the BrowserZap side panel');
815
+ try {
816
+ while (!cancelled) {
817
+ if (feedback.pendingCount({ session: SESSION_ID }) > 0) {
818
+ return feedbackTool(traceId, 'feedback_get', { images: args.images }, { oldest: true });
819
+ }
820
+ const left = deadline - Date.now();
821
+ if (left <= 0) break;
822
+ await new Promise((resolve) => {
823
+ wake = resolve;
824
+ setTimeout(resolve, Math.min(FEEDBACK_POLL_MS, left));
825
+ });
826
+ }
827
+ const seconds = Math.round(waitMs / 1000);
828
+ const env = okEnvelope(traceId,
829
+ cancelled ? 'Stopped listening.' : `No message from the person in ${seconds} s.`,
830
+ { batch: null, timedOut: !cancelled });
831
+ if (!cancelled) env.nextAction = 'Call feedback_wait again to keep listening, or stop when the person is done.';
832
+ return { env, extra: [] };
833
+ } finally {
834
+ inflightByRequest.delete(requestId);
835
+ feedbackWaiters.delete(nudge);
836
+ updateListening(false);
837
+ }
730
838
  }
731
839
 
732
840
  // ─── Workflows and plan (docs/WORKFLOWS.md, docs/PRO.md) ─────────────────────
@@ -1014,7 +1122,7 @@ const backgroundRuns = createRunRegistry({
1014
1122
  });
1015
1123
 
1016
1124
  /** Tools that only read, allowed while a background run drives the tab. */
1017
- const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles']);
1125
+ const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles', 'chat_reply']);
1018
1126
 
1019
1127
  /**
1020
1128
  * "5 of 5 done" / "3 of 5 done, 2 need attention" as a Chrome notification
@@ -1100,6 +1208,38 @@ function planEvidence() {
1100
1208
  return { plan, licenseStatus, features, limits, expiresAt, notice, proPackage: Boolean(pro), recording: recorder ? recorder.name : null };
1101
1209
  }
1102
1210
 
1211
+ /** The profile a status/report should speak about: the selected one, else the first. */
1212
+ function relevantProfile(profiles) {
1213
+ if (!Array.isArray(profiles) || profiles.length === 0) return null;
1214
+ return profiles.find((p) => p.selected) ?? profiles[0];
1215
+ }
1216
+
1217
+ /** The version the connected extension reported, or null when it is older than 0.7.0 (WP5). */
1218
+ async function connectedExtensionVersion() {
1219
+ try {
1220
+ const reply = await bridgeRequest('profiles_list', {}, 1500);
1221
+ if (reply?.ok !== true) return null;
1222
+ const profile = relevantProfile(reply.data?.profiles);
1223
+ return typeof profile?.extensionVersion === 'string' ? profile.extensionVersion : null;
1224
+ } catch {
1225
+ return null;
1226
+ }
1227
+ }
1228
+
1229
+ /**
1230
+ * What browser_status says about a store build that lags this bridge (WP5, F4).
1231
+ * A notice only: `ok` never changes and no call is ever refused for it.
1232
+ */
1233
+ function extensionVersionNotice(version) {
1234
+ if (version === null) {
1235
+ return 'The BrowserZap extension did not report its version, which means it is older than 0.7.0. Chrome updates store extensions automatically; to update now open chrome://extensions → Update.';
1236
+ }
1237
+ if (isOlderThan(version, MIN_EXTENSION_VERSION)) {
1238
+ return `The BrowserZap extension is older than this bridge (${version} < ${MIN_EXTENSION_VERSION}). Chrome updates store extensions automatically; to update now open chrome://extensions → Update.`;
1239
+ }
1240
+ return null;
1241
+ }
1242
+
1103
1243
  async function statusTool(traceId, args) {
1104
1244
  const evidence = {
1105
1245
  bridge: sessionStarted ? 'connected' : lastCloseCode === 4003 ? 'unauthorized' : lastCloseCode === 4005 ? 'version_mismatch' : 'unreachable',
@@ -1117,6 +1257,7 @@ async function statusTool(traceId, args) {
1117
1257
  sessionLog: process.env.BROWSERZAP_LOG === 'off' ? null : sessionLog.file(),
1118
1258
  screenshots: { folder: screenshots.screenshotDir(), keepDays: screenshots.keepDays() },
1119
1259
  profiles: null,
1260
+ extensionVersion: null,
1120
1261
  };
1121
1262
  if (!sessionStarted) {
1122
1263
  try {
@@ -1131,12 +1272,17 @@ async function statusTool(traceId, args) {
1131
1272
  const reply = await bridgeRequest('profiles_list', {}, 1500);
1132
1273
  evidence.profiles = reply.data?.profiles ?? null;
1133
1274
  } catch { /* an older bridge: no profile list */ }
1275
+ evidence.extensionVersion = relevantProfile(evidence.profiles)?.extensionVersion ?? null;
1134
1276
  if (Array.isArray(evidence.profiles) && evidence.profiles.length > 1 && !evidence.profiles.some((p) => p.selected)) {
1277
+ // The extension did answer (with its profile list), so the version notice
1278
+ // belongs here too; the error itself is unchanged.
1279
+ const notice = extensionVersionNotice(evidence.extensionVersion);
1135
1280
  return {
1136
1281
  ...errorEnvelope(traceId, 'PROFILE_REQUIRED',
1137
1282
  `${evidence.profiles.length} Chrome profiles are connected (${evidence.profiles.map((p) => `"${p.label}"`).join(', ')}). Choose one first.`,
1138
1283
  'Call browser_select_profile with the profile the user means.'),
1139
1284
  evidence: { ...evidence, extension: 'connected' },
1285
+ ...(notice ? { notice } : {}),
1140
1286
  };
1141
1287
  }
1142
1288
  const call = startAction('status', { includeTab: args.includeTab === true }, STATUS_DEADLINE_MS);
@@ -1144,9 +1290,11 @@ async function statusTool(traceId, args) {
1144
1290
  const result = await call.promise;
1145
1291
  const env = envelope(call.id, result);
1146
1292
  env.evidence = { ...evidence, extension: 'connected' };
1147
- const waiting = feedback.pendingCount();
1293
+ const notice = extensionVersionNotice(evidence.extensionVersion);
1294
+ if (notice) env.notice = notice;
1295
+ const waiting = feedback.pendingCount({ session: SESSION_ID });
1148
1296
  if (waiting && typeof env.summary === 'string') {
1149
- env.summary = `${env.summary} The person sent ${waiting} comment batch(es) from Comment mode: call feedback_get.`;
1297
+ env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
1150
1298
  }
1151
1299
  return env;
1152
1300
  } catch {
@@ -1211,7 +1359,9 @@ async function handleRequest(request) {
1211
1359
  'browser_new_tab only for a side trip (research, a comparison) and close it with browser_close_tab when done, ' +
1212
1360
  'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends. ' +
1213
1361
  'When the user mentions their BrowserZap feedback or comments, call feedback_get: it returns the notes they ' +
1214
- 'made on page elements in Comment mode, with components, source files and screenshots. When debugging a page, ' +
1362
+ 'made on page elements in Comment mode, with components, source files and screenshots. The user can also type ' +
1363
+ 'to you in the side panel: answer with chat_reply. When they ask you to listen to them in BrowserZap, call ' +
1364
+ 'feedback_wait, act or answer, and call it again until they are done. When debugging a page, ' +
1215
1365
  'page_console and page_network show what it logged and requested. A click that downloads a file says so; ' +
1216
1366
  'browser_downloads lists and waits for downloads. file_upload always needs the user\'s approval per file.',
1217
1367
  });