browserzap 0.7.0 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,229 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5 — typing that editors accept, fewer prompts (2026-11-05)
4
+
5
+ A blocker fix for text input. On Medium (and in Gmail, LinkedIn, X, Slack and Notion) the agent's typing could look
6
+ right on screen while the editor itself rejected it — Medium showed “Something is wrong and we cannot save your
7
+ story” and BrowserZap still said “verified”. Typing now goes through Chrome's own input channel, at the caret, exactly
8
+ as your keyboard does, and “verified” means the page kept the text.
9
+
10
+ **Permissions: fewer prompts, same safety**
11
+ - **Preparation goes ahead, a final action asks.** Attaching a file you named no longer asks. Submitting, sending,
12
+ deleting and paying still ask: **Approve once**, **Deny**, or the new **Always allow on this site**.
13
+ - **Settings → Permissions.** Set each kind of action to Allow, Ask or Block, and see (and remove) the sites you
14
+ allowed. Block always wins; payments can only Ask or Block; the agent can never change these settings.
15
+
16
+ **Settings on their own page**
17
+ - The gear in the side panel opens a full Settings page (also Chrome's “Options” for BrowserZap): General,
18
+ Permissions, Plan, Help and Advanced, laid out for a wide window and for a phone-width one.
19
+
20
+ **Writing in editors (found writing a real Medium story)**
21
+ - **Every character is a real keystroke**, as Chrome sends it for your keyboard (Shift included). Medium ignored text
22
+ that arrived any other way while BrowserZap reported it as kept.
23
+ - **The agent types where it clicked.** Click a title, then fill: the text goes into the title, not to the end of the
24
+ story. Clicking the text of an editor never asks for approval, whatever words it contains.
25
+ - **Uploads behind a button.** `file_upload` accepts a button like “Add an image”: BrowserZap catches the file chooser
26
+ the page opens (no dialog appears) and attaches the file you approved — Medium, Gmail and Notion work this way.
27
+ - **Honest results:** a list that “1. ” turned into is reported as converted, not lost; quotes the page curls are
28
+ “reformatted”; an icon-only toolbar button gets a name from its icon or hint (`nameSource: "hint"`).
29
+
30
+ **The agent's cursor**
31
+ - White with an orange outline, the same on light and dark pages. It stays on the element it acted on when the page
32
+ scrolls, and a cursor left behind by an extension reload is removed instead of shown twice.
33
+
34
+ **Typing**
35
+ - **Rich editors accept what the agent types.** BrowserZap no longer rewrites an editor's page. It clicks where the
36
+ caret belongs and types there, so the editor sees the same events as a person's keyboard: Medium, Gmail, LinkedIn,
37
+ X, Slack, Notion and comment boxes work.
38
+ - **A whole document is never cleared.** When the editor holds several blocks (Medium's story editor), the text is
39
+ added at the end of the last block, and the result says so. A single block is replaced only when that is what you
40
+ asked for.
41
+ - **Every keystroke is real.** Search boxes show suggestions as the agent types, autocompletes open, phone and card
42
+ masks format, character counters count, and shortcuts work — because the page receives real key events, not a
43
+ silent value change.
44
+ - **`verified` is honest.** BrowserZap reads the field back after a moment. If the page kept the text but reformatted
45
+ it (a phone mask, uppercase in a controlled field), the result says “reformatted by the page”. If it did not keep it,
46
+ the call fails with “the page did not keep the text” instead of pretending.
47
+ - **Background tabs behave like the tab in front.** The agent types and clicks without bringing your tab forward, and
48
+ without you losing your place.
49
+
50
+ **Keys, hover and scrolling**
51
+ - **Any key, and shortcuts.** `browser_press_key` now takes letters, digits and chords such as `ctrl+b`, `ctrl+k`,
52
+ `ctrl+a`, `ctrl+alt+1` and `ctrl+enter`. A bad key is refused before anything happens. Enter and Ctrl+Enter keep the
53
+ same approval rule when they would submit or send.
54
+ - **Hover.** New `element_hover`: menus, tooltips and buttons that appear only on hover can now be reached. It is
55
+ read-only, and it reports what appeared.
56
+ - **Scrolling loads more.** `browser_scroll` sends a real mouse wheel, so infinite lists and wheel-driven widgets load
57
+ the next page. The old behaviour is the fallback.
58
+
59
+ **Tabs**
60
+ - **Your agent keeps its tab.** If you click, type or navigate in the agent's tab, or look at another tab in its
61
+ group, the agent carries on in its own tab instead of stopping. Its next result says what changed while it was away
62
+ (address, title, whether fields were filled or emptied — never the values) and tells it to look at the page first.
63
+ - **A tab you shared still comes back** when you close its panel. Opening or closing the panel on a tab the agent
64
+ opened changes nothing.
65
+ - **Tab groups say BrowserZap.** An agent's group is titled `BrowserZap · claude-code` (or the name the agent gave
66
+ its session), so its tabs are easy to tell apart from another extension's groups.
67
+
68
+ **Updates**
69
+ - **Store updates install without a Chrome restart.** BrowserZap's connection to the bridge kept Chrome from ever
70
+ installing a downloaded update until the browser restarted. It now reloads into the new version as soon as no agent
71
+ is working, no side panel is open and Comment mode is off. A developer (unpacked) copy still needs its reload button.
72
+ - **No new permissions.** Same Chrome Web Store permissions as 0.8.0.
73
+
74
+ ## 0.8.0 — the connection fix and chat (2026-11-04)
75
+
76
+ Compared with the last public release (0.7.0). Only finished, tested work is listed. Everything is free during early
77
+ access; nothing that keeps you safe (approvals, hand-off, Stop) will ever be paid.
78
+
79
+ **It connects, and stays connected**
80
+ - **The side panel no longer sticks on "Connecting…".** If the connection came up before the panel started listening,
81
+ 0.7.0 could show "Connecting…" forever while everything underneath worked. The panel now always picks it up.
82
+ - **The bridge comes back by itself after the extension reloads or updates.** Chrome updating BrowserZap no longer
83
+ leaves it waiting until your agent's next call or a manual `browserzap --bridge`.
84
+ - **Profile names stay put.** An unnamed profile keeps its number ("Profile 1") across reconnects and restarts, and
85
+ renaming one profile never changes another.
86
+
87
+ **Chat with your agent in the side panel**
88
+ - **Type to your agent in any tab's panel**, or point at elements and ask about them in the same message.
89
+ - **Your agent can listen** (`feedback_wait`): ask it to "listen to me in BrowserZap" and it answers as soon as you
90
+ write; the panel shows when it is listening. It replies in the tab you wrote from (`chat_reply`).
91
+ - **Each message goes to the agent working in that tab**, never to another agent you have open.
92
+ - **Each tab group's panel shows its own conversation**, not everything the agent did elsewhere.
93
+ - **A message is never lost because the agent went away.** If the agent you wrote to has ended, the message is handed
94
+ to the next agent for that tab instead of sitting unread. The panel tells you which happened: "Sent to … — it reads
95
+ this on its next step", or "Saved. No agent is connected to this tab right now; the next one reads it."
96
+ - **You can see it working.** The panel shows a one-line status ("● Claude is working: Clicking “Submit”") while a
97
+ call is in flight, and flips to a reply in the same tab as soon as the agent answers.
98
+ - **The conversation stays with the tab.** Close the panel, switch tabs, or restart Chrome and the tab's chat is still
99
+ there, stored on your computer and compressed (Settings → Chat history: 7 days, 30 days, or off). Clear conversation
100
+ really clears it.
101
+ - **And only with that tab.** Close a tab and its chat goes with it; reopen the tab (Ctrl+Shift+T) or let Chrome
102
+ restore it and the chat comes back. A new tab on the same site starts a fresh conversation.
103
+ - **Answers arrive as soon as you send.** A listening agent is told the moment you press Send, instead of noticing on
104
+ its next check.
105
+ - **A calmer conversation.** Connection messages stay in the header, and a long run of agent steps folds to its last
106
+ two, with the earlier ones a click away.
107
+
108
+ **Free during early access**
109
+ - **No limit on saved workflows** (0.7.0 kept 10).
110
+
111
+ **Clearer approvals**
112
+ - **The approval card says who presses the button:** "Press Approve once here — the agent then presses the button
113
+ itself."
114
+ - **Picking a tab, radio button or option no longer asks for approval** because its label says "Approve" or "Delete";
115
+ buttons, links and consent checkboxes still do.
116
+
117
+ **Settings, redesigned**
118
+ - Clear sections for this browser (connection, version, the profile name agents see), the plan, help links, and the
119
+ advanced pairing options.
120
+
121
+ **Also in this release**
122
+
123
+ **Work with your agent in more than one tab**
124
+ - **Open BrowserZap on any tab and it joins your agent.** With one agent connected, opening the side panel on a tab
125
+ gives the agent that tab as a new workspace: its own Chrome tab group labelled "<agent> · <site>", up to four per
126
+ agent. The agent is told and switches to it when it needs it; nothing moves under it.
127
+ - **Several agents side by side.** Run two agents (two Claude Code or Codex windows) and each keeps to its own tab
128
+ groups. When you open the panel on a new tab, it asks which agent the tab is for. One agent can never act in
129
+ another's tabs.
130
+ - **Your tabs stay yours.** Tabs you bring in from the panel are never closed or trimmed by the agent, and closing a
131
+ tab's panel hands that tab back as it is.
132
+ - **No more duplicate tabs after a restart.** When Chrome restarts BrowserZap's background service (after a while
133
+ idle, or when the extension updates), your agent takes back its own tab and group instead of opening new ones.
134
+
135
+ **Clearer on the page**
136
+ - **The approval card never covers a form field.** Approve once / Deny sits beside or below the button it asks
137
+ about, on the button's row, clear of every input.
138
+ - **A new agent cursor:** a gold arrow (Lucide's mouse pointer). While the agent types, the arrow steps aside and the
139
+ field's own blinking caret shows where the text goes, with the field's name beside it — never the value.
140
+ - **Panels on other tabs say where your agent is** ("working in another tab", with Show), and every tab whose panel
141
+ is open is marked **ON** in the toolbar.
142
+ - **Two copies of BrowserZap in one profile are called out** (for example the store version and a developer build),
143
+ with which one to turn off. Unnamed Chrome profiles get distinct names.
144
+ - First visit explains Chrome's "BrowserZap started debugging this browser" bar, and that pressing Cancel stops the
145
+ agent. The status pills in Workflows are easier to read (higher contrast), and the panel uses Lucide icons.
146
+
147
+ **More accurate results**
148
+ - A click that submits a form now reports a navigation that happens a moment later, instead of "no change".
149
+ - Chrome's debugging bar no longer flickers mid-task (BrowserZap lets go after 5 idle minutes, and at once when the
150
+ agent finishes).
151
+
152
+ **For developers**
153
+ - `browserzap setup` names the store extension id it registered in store-only mode.
154
+ - `npm run release:changes`, `release:public` and `release:internal` (docs/RELEASE_CHECKLIST.md).
155
+
156
+ ## 0.8.0 (internal)
157
+
158
+ **Internal only.** Built and installed from the checkout for the owner's own use (`browserzap-0.8.0.tgz`, packed
159
+ locally); never published to npm, the Chrome Web Store or the website. Branch `integration/0.8` → `release/0.8`.
160
+ Full browser suite and unit tests: see docs/releases/0.8.0-internal.md.
161
+
162
+ ### Tabs, workspaces and several agents
163
+ - **Workspaces.** Opening the side panel on a tab with one agent connected adds the tab to that session as a new
164
+ workspace group "<agent> · <site>" (≤ 4 groups per session, `MAX_WORKSPACES_PER_SESSION`). Panels already open when
165
+ an agent connects join it too. The agent gets a note on its next call; `browser_list_tabs` returns
166
+ `role: "workspace"` and `workspace: "<label>"` (read back from Chrome after a restart). Workspace tabs are the
167
+ person's (`sharedTabs`): never trimmed, kept and ungrouped when the session ends, released when their panel closes.
168
+ - **Several agents.** With more than one agent connected the panel shows a picker (`join_tab`); nothing joins on its
169
+ own. `tabBelongs` checks the session's group and its workspace groups; another session's tab is
170
+ `PERMISSION_REQUIRED`. Proven with two real MCP clients interleaving navigate, fill, chat, approval and click (B-04,
171
+ `concurrent-agents.e2e.mjs`).
172
+ - **"Work here".** Sharing a tab when exactly one agent is connected moves that agent there (`giveTabToAgent`): the
173
+ tab joins its group, becomes its session tab, and the next page action is stopped once with the reason
174
+ (`tabNotice`, D-21). Panels on tabs without an agent name the agent busy elsewhere, with Show; the header says
175
+ "N agents in another tab".
176
+ - **Reclaim after a restart.** The saved session map is merged, not overwritten (`persistSessions`), so the first
177
+ session to come back after a service-worker restart no longer erases the others; an entry is removed only when its
178
+ session ends (`forgetSavedSession`). A debugger attachment left by the previous worker is taken back
179
+ (`reclaimOwnAttachment`: only our own attachment can be detached) instead of failing with "Another debugger is
180
+ already attached".
181
+ - **The bridge comes back by itself (B-10).** After an extension reload, a paired extension now asks the native
182
+ helper to start the bridge when its connection fails, instead of waiting for an agent or a manual
183
+ `browserzap --bridge`. The helper runs ensure-bridge, the extension reconnects, and it stops asking while the
184
+ connection is healthy.
185
+ - **Debugger hold on the action's tab.** The no-detach-mid-action hold now covers the tab an action actually runs on
186
+ (a session's first tab, or one switched to mid-call), not only the session tab at the start of the call.
187
+
188
+ ### Side panel and page
189
+ - **Chat (internal, Pro).** The message box is on in internal builds (`src/sidepanel/features.ts`); a message is a
190
+ feedback batch with `kind: "message"`; every tool result reminds the agent until `feedback_get`; `chat_reply`
191
+ answers in the panel (offered with `BROWSERZAP_EXPERIMENTAL=chat`). Before any public release it must be off or
192
+ gated by the licence.
193
+ - **Approval card placement** (B-01): every candidate spot is first moved on screen, then judged by what it covers
194
+ (other controls and the marked element itself); if none is clear it slides along the same rows until it is; on a
195
+ page with no clear spot it takes the one that covers least. Rules unchanged: trusted clicks only, `tabIndex = -1`,
196
+ out of the agent's reach.
197
+ - **Cursor:** Lucide `mouse-pointer-2`, gold (`#f5b335`) with a dark outline. The drawn I-beam caret is removed: a
198
+ typing step hides the arrow (decided by the step, not by focus) and shows only the field's name.
199
+ - **Icons:** `lucide-preact` (ISC) for the panel's settings, send, pin, copy and close; brand marks unchanged.
200
+ - **ON badge** on every tab whose panel is open (B-03); duplicate-build warning and never-reused profile numbers
201
+ (B-05); the debugging bar explained on first visit (B-08); darker text on the status pills (axe contrast).
202
+ - The panel subscribes to state in a layout effect and connects its tab port at load, so a panel that has not painted
203
+ yet still receives updates and hands its tab back when closed.
204
+ - Late navigation after a click that started a request is reported (B-02, `NAVIGATION_FOLLOW_MS`).
205
+
206
+ ### Tests and release tooling
207
+ - **Browser suite: ~15 → ~12 minutes** for the full lane: compile-time E2E build (`build:e2e` → `extension-e2e/`,
208
+ short `DEBUGGER_IDLE_MS` / `TAB_TRIM_IDLE_MS` / `SESSION_GRACE_MS`; production values pinned by the contract test),
209
+ `expect` on clicks, `until()` instead of fixed sleeps, grouped files, lanes `test:e2e:fast`, `test:e2e`,
210
+ `test:e2e:real-timers`. The debugger idle case dropped from 5 min to ~5 s.
211
+ - **New checks:** console/exception guard on BrowserZap's own pages and worker, panel layout at 320/420/600 px,
212
+ axe-core accessibility, keyboard flow, service-worker suspension, orphan-process and redaction scans, failure
213
+ bundles in `artifacts/e2e-failures/`, JSON timings.
214
+ - **Build guard:** the E2E build compiles into `dist/chrome-e2e` with a marker the store packager refuses.
215
+ - **Fixed traps:** the harness no longer attaches to web pages (it made every tab look "debugged" and held up new
216
+ tabs); `test:e2e` always runs every test — re-running only failures is `test:e2e:rerun-failed`, never a release
217
+ lane.
218
+ - Store records per release (`docs/store/`, browser view `docs/store/index.html`) and the public/internal release
219
+ checklist (`docs/RELEASE_CHECKLIST.md`) — shipped with 0.7.0's records.
220
+
221
+ ### Cleanup
222
+ - 13 finished worktrees and 46 merged local branches removed; 33 merged remote branches deleted (backup bundle first);
223
+ GitHub deletes head branches on merge. Finished plans and reports moved to `docs/archive/` (links updated).
224
+ Branch, worktree and push-batching rules in `docs/DEVELOPMENT_WORKFLOW.md`. Architecture plan:
225
+ `docs/design/FEATURE_MODULES.md`.
226
+
3
227
  ## 0.7.0 — public launch
4
228
 
5
229
  BrowserZap is public: free, and installed from the Chrome Web Store.
@@ -48,7 +272,7 @@ BrowserZap is public: free, and installed from the Chrome Web Store.
48
272
 
49
273
  ## 0.6.1 (2026-10-30)
50
274
 
51
- The changes from three real-Chrome runs in the owner's own browser (docs/REVIEW_LATER.md, K-7…K-10). Checked in the
275
+ The changes from three real-Chrome runs in the owner's own browser (docs/archive/REVIEW_LATER.md, K-7…K-10). Checked in the
52
276
  owner's Chrome: `npm run check:chrome` 9/9, approval through the page card, the test tab closed at session end.
53
277
 
54
278
  - **No more tabs left behind.** The tab group is the contract: while the agent is connected its tabs stay open in the
@@ -104,7 +328,7 @@ A replay now waits for what the step is actually blocked on, and retries that st
104
328
 
105
329
  ## 0.5.5 (2026-10-30)
106
330
 
107
- Comment mode, debugging tools, files, Edge and WSL, a much better doctor, and the Chrome Web Store build. Known items to review are listed in docs/REVIEW_LATER.md (upload approval visibility, a file the person picks themselves, a download re-test, wider Comment screenshots, a calmer side panel).
331
+ Comment mode, debugging tools, files, Edge and WSL, a much better doctor, and the Chrome Web Store build. Known items to review are listed in docs/archive/REVIEW_LATER.md (upload approval visibility, a file the person picks themselves, a download re-test, wider Comment screenshots, a calmer side panel).
108
332
 
109
333
  - No more stuck file dialogs: when the agent clicks a page's "Choose file" control, no Windows file picker opens; the result says the page asked for a file and to use `file_upload`. Your own clicks open the picker as usual. If Chrome is set to ask where to save each download, `browserzap doctor` and `browser_downloads` say so and how to turn it off (an extension cannot skip that dialog).
110
334
  - Messages for people who installed from npm: the side panel, the extension and the agent no longer point to source-only commands (`npm run health`, `npm run bridge:restart`, …) but to `browserzap doctor`, `browserzap restart-bridge`, `browserzap --print-token`. When a bridge from another install holds the port, the side panel now says so and shows the fix instead of staying grey.
@@ -79,6 +79,7 @@ No copyleft, source-available-restricted or non-commercial licences are present
79
79
  |---|---|---|
80
80
  | `ws` | MIT | WebSocket transport for the local bridge |
81
81
  | `preact` | MIT | Side-panel UI, bundled into the extension's `sidepanel.js` (Copyright (c) 2015-present Jason Miller) |
82
+ | `lucide-preact` | ISC | Side-panel icons, bundled into `sidepanel.js`; the page cursor draws Lucide's `mouse-pointer-2` path (Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT); all other copyright (c) for Lucide are held by Lucide Contributors 2022) |
82
83
 
83
84
  ### Build-time only (not redistributed)
84
85
 
@@ -257,7 +257,7 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
257
257
  /**
258
258
  * The part of a finished batch an agent needs: counts and, per set that did not
259
259
  * complete, where and why. Everything else is in the report. Kept well under
260
- * 700 bytes per set (docs/NEXT_RELEASE_SCOPE.md P0-5).
260
+ * 700 bytes per set (docs/archive/NEXT_RELEASE_SCOPE.md P0-5).
261
261
  */
262
262
  function compactBatch(result) {
263
263
  const ev = result?.evidence ?? {};
@@ -2,9 +2,8 @@
2
2
  * BrowserZap — plan and entitlements (docs/ADR/ADR-006-free-pro-boundary.md).
3
3
  *
4
4
  * The free core is everything that keeps an agent safe and useful on its own:
5
- * every browser tool, approvals, handoff, honest errors and basic workflows.
6
- * Pro adds workflow history, bulk runs, run reports and more than
7
- * FREE_LIMITS.workflows saved workflows. Nothing here ever gates a safety check.
5
+ * every browser tool, approvals, handoff, honest errors and workflows.
6
+ * Pro adds workflow history, bulk runs and run reports. Nothing here ever gates a safety check.
8
7
  *
9
8
  * A licence is a signed token issued by the licence service after payment:
10
9
  *
@@ -36,15 +35,18 @@ const LICENSE_FILENAME = 'license';
36
35
  const GRACE_DAYS = 14;
37
36
  const DAY_S = 24 * 60 * 60;
38
37
 
39
- /** Limits that apply without a licence. */
40
- const FREE_LIMITS = Object.freeze({ workflows: 10 });
38
+ /**
39
+ * Limits that apply without a licence. Early access (owner decision 2026-11-03, docs/DECISIONS.md D-0.8-1): no
40
+ * limit on saved workflows; the site promises every shipped feature free. A cap returns only with notice on the site.
41
+ */
42
+ const FREE_LIMITS = Object.freeze({});
41
43
 
42
44
  /** Every Pro feature, with the text shown when it is missing. */
43
45
  const PRO_FEATURES = Object.freeze({
44
46
  'workflow.history': 'keeps every saved version of a workflow and can run or restore an older one',
45
47
  'workflow.bulk': 'runs one workflow over many parameter sets in a single call',
46
48
  'workflow.reports': 'keeps a local report of every workflow run',
47
- 'workflow.unlimited': `saves more than ${FREE_LIMITS.workflows} workflows`,
49
+ 'workflow.unlimited': 'saves any number of workflows',
48
50
  });
49
51
 
50
52
  const PLAN_FEATURES = Object.freeze({
@@ -174,7 +174,11 @@ function saveBatch(batch, { now = new Date() } = {}) {
174
174
 
175
175
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
176
176
  for (const [name, data] of files) writePrivate(path.join(dir, name), data);
177
- 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 };
178
182
  writePrivate(path.join(dir, 'batch.json'), JSON.stringify(record, null, 2));
179
183
  prune({ now: now.getTime() });
180
184
  return { id, dir, count: stored.length };
@@ -197,25 +201,32 @@ function batchIds() {
197
201
  }
198
202
  }
199
203
 
200
- /** Newest first: { id, createdAt, count, pages, delivered }. */
201
- function list({ includeDelivered = false } = {}) {
202
- 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())
203
213
  .map((id) => readBatch(id))
204
- .filter((b) => b && (includeDelivered || !b.delivered))
214
+ .filter((b) => b && (includeDelivered || !b.delivered) && forSession(b, session))
205
215
  .map((b) => ({
206
216
  id: b.id,
207
217
  createdAt: b.createdAt,
208
218
  count: b.comments.length,
209
219
  pages: [...new Set(b.comments.map((c) => c.page?.url).filter(Boolean))].slice(0, 5),
210
220
  delivered: b.delivered,
221
+ session: b.session ?? null,
211
222
  }));
212
223
  }
213
224
 
214
- /** One batch with absolute file paths, or the newest one not yet delivered. */
215
- function get(id) {
216
- 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;
217
228
  const batch = chosen ? readBatch(chosen) : null;
218
- if (!batch) return null;
229
+ if (!batch || !forSession(batch, session)) return null;
219
230
  const dir = path.join(feedbackDir(), batch.id);
220
231
  return {
221
232
  ...batch,
@@ -228,6 +239,26 @@ function get(id) {
228
239
  };
229
240
  }
230
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
+
231
262
  function markDelivered(id, { now = new Date() } = {}) {
232
263
  const batch = readBatch(id);
233
264
  if (!batch || batch.delivered) return false;
@@ -237,8 +268,8 @@ function markDelivered(id, { now = new Date() } = {}) {
237
268
  return true;
238
269
  }
239
270
 
240
- function pendingCount() {
241
- return list().length;
271
+ function pendingCount({ session = null } = {}) {
272
+ return list({ session }).length;
242
273
  }
243
274
 
244
275
  /** Delete batches older than the retention, and the oldest beyond MAX_BATCHES. Never throws. */
@@ -259,4 +290,4 @@ function prune({ now = Date.now(), days = keepDays() } = {}) {
259
290
  return removed;
260
291
  }
261
292
 
262
- 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 };
@@ -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). */
@@ -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,7 +30,7 @@ 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
35
  const { MIN_EXTENSION_VERSION, isOlderThan } = require('./extension-version');
36
36
  const { MCP_TOOLS, LOCAL_TOOLS, validateArguments } = require('./tools');
@@ -161,6 +161,12 @@ function connectToBridge() {
161
161
  }
162
162
  return;
163
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
+ }
164
170
  // The person pressed Stop on the page (or took control in the side panel):
165
171
  // a background batch stops with the step in flight.
166
172
  if (msg.type === 'event' && msg.event === 'user_handoff') {
@@ -224,6 +230,26 @@ function updateSessionName(name) {
224
230
  }
225
231
  }
226
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
+
227
253
  /** Requests answered by the bridge itself (Chrome profiles), matched by id. */
228
254
  const bridgeReplies = new Map();
229
255
  let bridgeSeq = 0;
@@ -486,7 +512,7 @@ async function callToolWith(progress, requestId, toolName, args) {
486
512
  // The person can message the agent from the side panel at any time; the
487
513
  // agent learns of it on its next call. browser_status says it itself.
488
514
  if (toolName !== 'browser_status' && !toolName.startsWith('feedback_') && typeof env.summary === 'string') {
489
- const waiting = feedback.pendingCount();
515
+ const waiting = feedback.pendingCount({ session: SESSION_ID });
490
516
  if (waiting) env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
491
517
  }
492
518
  log('tool_call', {
@@ -526,6 +552,11 @@ async function callToolWith(progress, requestId, toolName, args) {
526
552
  return finish(env, extra);
527
553
  }
528
554
 
555
+ if (toolName === 'feedback_wait') {
556
+ const { env, extra } = await feedbackWait(requestId, traceId, args, progress);
557
+ return finish(env, extra);
558
+ }
559
+
529
560
  if (LOCAL_TOOLS.has(toolName)) return finish(await workflowTool(requestId, traceId, toolName, args, progress));
530
561
  // A background run is driving this session's tab: reading is fine, acting would interleave with its steps.
531
562
  const running = backgroundRuns.active();
@@ -713,9 +744,9 @@ const FEEDBACK_INLINE_IMAGES = 12;
713
744
  const FEEDBACK_INLINE_BYTES = 6 * 1024 * 1024;
714
745
  const MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
715
746
 
716
- function feedbackTool(traceId, toolName, args) {
747
+ function feedbackTool(traceId, toolName, args, options = {}) {
717
748
  if (toolName === 'feedback_list') {
718
- const batches = feedback.list({ includeDelivered: args.includeDelivered === true });
749
+ const batches = feedback.list({ includeDelivered: args.includeDelivered === true, session: SESSION_ID });
719
750
  const pending = batches.filter((b) => !b.delivered).length;
720
751
  return {
721
752
  env: okEnvelope(traceId, batches.length
@@ -724,7 +755,7 @@ function feedbackTool(traceId, toolName, args) {
724
755
  extra: [],
725
756
  };
726
757
  }
727
- const batch = feedback.get(args.id);
758
+ const batch = feedback.get(args.id, { session: SESSION_ID, oldest: options.oldest === true });
728
759
  if (!batch) {
729
760
  return {
730
761
  env: args.id
@@ -734,6 +765,8 @@ function feedbackTool(traceId, toolName, args) {
734
765
  };
735
766
  }
736
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);
737
770
  const extra = [];
738
771
  let bytes = 0;
739
772
  if (args.images !== false) {
@@ -755,13 +788,53 @@ function feedbackTool(traceId, toolName, args) {
755
788
  const pinned = batch.comments.length - messages;
756
789
  const what = [messages ? `${messages} message(s)` : '', pinned ? `${pinned} comment(s)` : ''].filter(Boolean).join(' and ');
757
790
  const where = pages.length === 0 ? '' : ` on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}`;
758
- return {
759
- env: okEnvelope(traceId,
760
- `${what} from the person${where}.`,
761
- { batch },
762
- {}),
763
- extra,
764
- };
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
+ }
765
838
  }
766
839
 
767
840
  // ─── Workflows and plan (docs/WORKFLOWS.md, docs/PRO.md) ─────────────────────
@@ -1219,7 +1292,7 @@ async function statusTool(traceId, args) {
1219
1292
  env.evidence = { ...evidence, extension: 'connected' };
1220
1293
  const notice = extensionVersionNotice(evidence.extensionVersion);
1221
1294
  if (notice) env.notice = notice;
1222
- const waiting = feedback.pendingCount();
1295
+ const waiting = feedback.pendingCount({ session: SESSION_ID });
1223
1296
  if (waiting && typeof env.summary === 'string') {
1224
1297
  env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
1225
1298
  }
@@ -1286,7 +1359,9 @@ async function handleRequest(request) {
1286
1359
  'browser_new_tab only for a side trip (research, a comparison) and close it with browser_close_tab when done, ' +
1287
1360
  'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends. ' +
1288
1361
  'When the user mentions their BrowserZap feedback or comments, call feedback_get: it returns the notes they ' +
1289
- '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, ' +
1290
1365
  'page_console and page_network show what it logged and requested. A click that downloads a file says so; ' +
1291
1366
  'browser_downloads lists and waits for downloads. file_upload always needs the user\'s approval per file.',
1292
1367
  });
@@ -73,9 +73,10 @@ function writeLastRuns(runs) {
73
73
  * @param {object} io
74
74
  * @param {(msg: object, socket: object, profile: string) => boolean} io.deliver send an action for the panel session to that profile; false if it is not connected
75
75
  * @param {(msg: object, profile: string) => void} io.toExtension send a panel_event / cancel to that profile's extension
76
+ * @param {(session: string | null) => void} [io.onFeedback] a batch was saved for this session (null: any)
76
77
  * @param {(event: string, fields?: object) => void} [io.log]
77
78
  */
78
- function createPanelRunner({ deliver, toExtension, log = () => {} }) {
79
+ function createPanelRunner({ deliver, toExtension, isSessionLive = () => true, onFeedback = () => {}, log = () => {} }) {
79
80
  const service = createWorkflowService({ log });
80
81
  const sessionLog = createSessionLog({ session: PANEL_SESSION, client: () => 'side panel' });
81
82
  let current = null;
@@ -438,9 +439,14 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
438
439
  return { ok: true, data: {} };
439
440
  case 'feedback_submit': {
440
441
  // Comment mode (docs/COMMENT_MODE.md): stored on this device for the agent's feedback_get.
441
- const saved = feedback.saveBatch(args.batch);
442
- log('feedback_saved', { batch: saved.id, comments: saved.count });
443
- return { ok: true, data: { batchId: saved.id, count: saved.count } };
442
+ // Addressed to an agent that has gone? Then "any agent" (session null), never an id nobody can read.
443
+ const asked = args.batch && typeof args.batch.session === 'string' ? args.batch.session : null;
444
+ const live = asked ? isSessionLive(asked) : false;
445
+ const saved = feedback.saveBatch(live ? args.batch : { ...args.batch, session: null });
446
+ log('feedback_saved', { batch: saved.id, comments: saved.count, live });
447
+ // Wake a waiting feedback_wait now instead of on its next poll.
448
+ onFeedback(live ? asked : null);
449
+ return { ok: true, data: { batchId: saved.id, count: saved.count, session: live ? asked : null, live } };
444
450
  }
445
451
  default:
446
452
  return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Unknown panel request "${op}".` } };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stable names for connected browser profiles, kept per extension install id.
5
+ *
6
+ * An unnamed profile gets "Profile N" once and keeps it across reconnects and bridge restarts; the number is
7
+ * never handed to another install. A rename changes only that install. Names live in `profiles.json` in the
8
+ * config folder (no page data, only id → label). Without a file (tests) they live in memory.
9
+ */
10
+
11
+ const fs = require('fs');
12
+
13
+ const MAX_ENTRIES = 50;
14
+
15
+ function createProfileLabels(file = null) {
16
+ /** @type {Record<string, { label: string, custom: boolean, number?: number, seen: number }>} */
17
+ let entries = {};
18
+ if (file) {
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entries = parsed;
22
+ } catch {
23
+ /* first run or unreadable: start empty */
24
+ }
25
+ }
26
+
27
+ function save() {
28
+ const ids = Object.keys(entries);
29
+ if (ids.length > MAX_ENTRIES) {
30
+ ids.sort((a, b) => entries[a].seen - entries[b].seen).slice(0, ids.length - MAX_ENTRIES).forEach((id) => delete entries[id]);
31
+ }
32
+ if (!file) return;
33
+ try {
34
+ fs.writeFileSync(file, JSON.stringify(entries, null, 2), { mode: 0o600 });
35
+ } catch {
36
+ /* names still work for this run */
37
+ }
38
+ }
39
+
40
+ function nextNumber() {
41
+ let max = 0;
42
+ for (const entry of Object.values(entries)) if (typeof entry.number === 'number' && entry.number > max) max = entry.number;
43
+ return max + 1;
44
+ }
45
+
46
+ return {
47
+ /** The name for an install that just connected: what it sent, else what it had, else a new "Profile N". */
48
+ connect(id, sentLabel) {
49
+ const entry = entries[id];
50
+ if (sentLabel) {
51
+ // A named install takes no number: "Profile N" counts unnamed ones. It keeps one it already had.
52
+ entries[id] = { ...entry, label: sentLabel, custom: true, seen: Date.now() };
53
+ } else if (entry) {
54
+ entry.seen = Date.now();
55
+ } else {
56
+ const number = nextNumber();
57
+ entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
58
+ }
59
+ save();
60
+ return entries[id].label;
61
+ },
62
+ /** The person renamed this install; no other entry changes. */
63
+ rename(id, label) {
64
+ entries[id] = { ...entries[id], label, custom: true, seen: Date.now() };
65
+ save();
66
+ return label;
67
+ },
68
+ /** The person cleared the name: back to this install's own "Profile N". */
69
+ reset(id) {
70
+ const number = entries[id]?.number ?? nextNumber();
71
+ entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
72
+ save();
73
+ return entries[id].label;
74
+ },
75
+ get(id) {
76
+ return entries[id]?.label ?? null;
77
+ },
78
+ };
79
+ }
80
+
81
+ module.exports = { createProfileLabels };
@@ -12,7 +12,7 @@ const { LIMITS } = require('./limits');
12
12
  * step — so every wait and every retry still goes through the normal tool path
13
13
  * and extension policy.
14
14
  *
15
- * Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
15
+ * Design: docs/archive/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
16
16
  * the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
17
17
  *
18
18
  * Adding a resolver is one entry here plus a fixture that fails without it.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The bridge WebSocket is the only network listener in the system. Upstream
5
5
  * shipped it bound to 0.0.0.0 with no authentication at all (finding SEC-01 in
6
- * docs/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
6
+ * docs/archive/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
7
7
  * user's logged-in browser.
8
8
  *
9
9
  * BrowserZap closes that by requiring a shared secret on the first frame of every
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Derived from Open Browser Control (MIT, Copyright (c) 2026 smankoo) at commit
11
11
  * 4e6c262. Changes from upstream are marked "// BROWSERZAP:" and are summarised in
12
- * docs/PHASE_1_REPORT.md:
12
+ * docs/archive/PHASE_1_REPORT.md:
13
13
  *
14
14
  * - the listener binds 127.0.0.1 only and refuses to run otherwise (SEC-01)
15
15
  * - every connection must authenticate with the per-install secret before it
@@ -36,6 +36,8 @@ const { getOrCreateSecret, secretMatches, secretPath, configDir } = require('./s
36
36
  const pairing = require('./pairing');
37
37
  const { version: BRIDGE_VERSION } = require('../package.json');
38
38
  const { createPanelRunner } = require('./panel');
39
+ const feedback = require('./feedback');
40
+ const { createProfileLabels } = require('./profile-labels');
39
41
  const { LIMITS } = require('./limits');
40
42
 
41
43
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -71,7 +73,7 @@ const MAX_EXTENSION_MESSAGES_PER_SECOND = 500;
71
73
  // execution in the page is not part of this product.
72
74
  // Must match AGENT_ACTIONS in src/types/protocol.ts (tests/contract.test.mjs).
73
75
  const VALID_AGENT_ACTIONS = new Set([
74
- 'status', 'inspect', 'navigate', 'click', 'fill', 'select_option', 'wait_for',
76
+ 'status', 'inspect', 'navigate', 'click', 'hover', 'fill', 'select_option', 'wait_for',
75
77
  'screenshot', 'press_key', 'scroll', 'pause', 'resume',
76
78
  'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify', 'upload',
77
79
  'console', 'network', 'say',
@@ -108,6 +110,7 @@ function isValidMessage(msg) {
108
110
  }
109
111
  if (msg.type === 'cancel' && (typeof msg.id !== 'string' || !msg.id)) return false;
110
112
  if (msg.type === 'session_start' && typeof msg.name !== 'string') return false;
113
+ if (msg.type === 'session_update' && typeof msg.name !== 'string' && typeof msg.listening !== 'boolean' && !Number.isInteger(msg.chatTabId)) return false;
111
114
  return true;
112
115
  }
113
116
 
@@ -137,10 +140,13 @@ function isLoopbackAddress(address) {
137
140
  * decision, not a convenience.
138
141
  * @param {string} [options.secret] Expected pairing secret. Defaults to the on-disk secret.
139
142
  * @param {(msg: string) => void} [options.log]
143
+ * @param {object} [options.limits] Test seam only: timer values to use instead of `limits.js`. The E2E
144
+ * harness shortens the session grace period; no shipped build passes this.
140
145
  */
141
146
  function createBridgeServer(options = {}) {
142
147
  const port = options.port ?? DEFAULT_PORT;
143
148
  const host = options.host ?? BIND_HOST;
149
+ const limits = options.limits ?? LIMITS;
144
150
  const expectedSecret = options.secret ?? getOrCreateSecret();
145
151
  const log = options.log ?? ((msg) => process.stderr.write(`[browserzap-bridge] ${msg}\n`));
146
152
 
@@ -159,12 +165,19 @@ function createBridgeServer(options = {}) {
159
165
  * once (multi-profile, docs/MULTI_PROFILE.md). The same install reconnecting
160
166
  * replaces its own socket. `extensionVersion` is what the extension reported
161
167
  * in its hello (WP5); null when it is older and reports nothing.
162
- * @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number, extensionVersion: string | null }>}
168
+ * `extensionId` is the Chrome extension id: two different ids connected at
169
+ * once mean two different builds (store + developer), not one profile each (B-05).
170
+ * @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number, extensionVersion: string | null, extensionId: string | null }>}
163
171
  */
164
172
  const profiles = new Map();
165
173
  /** @type {Map<WebSocket, string>} */
166
174
  const socketToProfile = new Map();
167
175
  let anonymousProfiles = 0;
176
+ // Names per install id, kept on disk so a restart never renumbers profiles (B-05, 0.8 public).
177
+ // Tests that pass their own secret keep names in memory unless they name a file.
178
+ const labels = createProfileLabels(options.profileLabelsFile !== undefined
179
+ ? options.profileLabelsFile
180
+ : (options.secret ? null : path.join(configDir(), 'profiles.json')));
168
181
 
169
182
  /**
170
183
  * Agent sessions. `profile` is the browser profile the session acts in: set
@@ -184,10 +197,21 @@ function createBridgeServer(options = {}) {
184
197
  };
185
198
  const profileList = (selected) => [...profiles.entries()].map(([id, p]) => ({
186
199
  id, label: p.label, browser: p.browser, connectedAt: new Date(p.connectedAt).toISOString(), selected: id === selected,
187
- // What the store build said in its hello; null for a build older than 0.7.0.
200
+ // What the build said in its hello; null for a build older than 0.7.0 / 0.8.0.
188
201
  extensionVersion: p.extensionVersion ?? null,
202
+ extensionId: p.extensionId ?? null,
189
203
  }));
190
204
 
205
+ /**
206
+ * The extension view of the connected profiles, sent on every change so each
207
+ * side panel can warn when two different builds run at once (B-05). An older
208
+ * extension ignores the event, which is why it is additive.
209
+ */
210
+ function broadcastProfiles() {
211
+ const data = { profiles: profileList(null) };
212
+ for (const p of profiles.values()) safeSend(p.ws, { type: 'event', event: 'profiles', data });
213
+ }
214
+
191
215
  // The side panel's workflow runner: a built-in session that drives the
192
216
  // extension like an agent would, so every step gets the same checks. A run
193
217
  // acts in the profile whose side panel started it.
@@ -199,6 +223,19 @@ function createBridgeServer(options = {}) {
199
223
  return true;
200
224
  },
201
225
  toExtension: (msg, profile) => sendTo(profileSocket(profile), msg),
226
+ // Whether a named session still has a live socket: a message addressed to an agent that has gone is stored
227
+ // as "any agent" instead of being unreadable for ever (feedback.releaseOrphans).
228
+ isSessionLive: (id) => {
229
+ const session = agentSessions.get(id);
230
+ return Boolean(session && (session.builtIn || session.ws));
231
+ },
232
+ // A message was stored: tell the agent it is for (or every agent, when unaddressed) so a feedback_wait returns at
233
+ // once. Only a nudge; the agent still reads the batch from the folder, and an older MCP server ignores the event.
234
+ onFeedback: (session) => {
235
+ for (const [id, s] of agentSessions) {
236
+ if (!s.builtIn && s.ws && (!session || id === session)) safeSend(s.ws, { type: 'event', event: 'feedback_new' });
237
+ }
238
+ },
202
239
  log: (event, fields) => log(`panel ${event}${fields ? ` ${JSON.stringify(fields)}` : ''}`),
203
240
  });
204
241
  if (panel) agentSessions.set(panel.session, { ws: panel.socket, name: panel.sessionName, profile: null, builtIn: true });
@@ -434,9 +471,11 @@ function createBridgeServer(options = {}) {
434
471
  if (msg.type === 'profile_update') {
435
472
  const label = sanitizeSessionName(msg.label);
436
473
  const p = profiles.get(fromProfile);
437
- if (p && label) {
438
- p.label = label;
439
- log(`Profile renamed to "${label}"`);
474
+ if (p) {
475
+ // An empty name gives this install back its own "Profile N"; no other profile changes.
476
+ p.label = label ? labels.rename(fromProfile, label) : labels.reset(fromProfile);
477
+ log(`Profile renamed to "${p.label}"`);
478
+ broadcastProfiles();
440
479
  }
441
480
  return;
442
481
  }
@@ -470,13 +509,19 @@ function createBridgeServer(options = {}) {
470
509
  if (msg.type === 'session_update') {
471
510
  const session = agentSessions.get(sessionId);
472
511
  const name = sanitizeSessionName(msg.name);
473
- if (session && name) {
512
+ const listening = typeof msg.listening === 'boolean' ? msg.listening : undefined;
513
+ // The tab a reply belongs to when the agent read a message from a tab no session owned (updateChatTab).
514
+ const chatTabId = Number.isInteger(msg.chatTabId) && msg.chatTabId >= 0 ? msg.chatTabId : undefined;
515
+ if (!session || (!name && listening === undefined && chatTabId === undefined)) return;
516
+ if (name) {
474
517
  session.name = name;
475
- msg.name = name;
476
518
  log(`Agent "${session.name}" renamed (session: ${sessionId})`);
477
- } else {
478
- return;
479
519
  }
520
+ // Only these fields reach the extension.
521
+ for (const key of Object.keys(msg)) if (!['type', 'session'].includes(key)) delete msg[key];
522
+ if (name) msg.name = name;
523
+ if (listening !== undefined) msg.listening = listening;
524
+ if (chatTabId !== undefined) msg.chatTabId = chatTabId;
480
525
  }
481
526
  // The bridge, not the agent, decides which session a message belongs to.
482
527
  msg.session = sessionId;
@@ -527,6 +572,7 @@ function createBridgeServer(options = {}) {
527
572
  if (profiles.get(profileId)?.ws === ws) {
528
573
  log(`Browser profile "${profiles.get(profileId).label}" disconnected`);
529
574
  profiles.delete(profileId);
575
+ broadcastProfiles();
530
576
  }
531
577
  return;
532
578
  }
@@ -538,11 +584,11 @@ function createBridgeServer(options = {}) {
538
584
  // The connection dropped without a goodbye: the agent may come back with
539
585
  // the same session id (a restart, a blip), so its tabs wait
540
586
  // SESSION_GRACE_MS before the session ends (D-38).
541
- log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${LIMITS.SESSION_GRACE_MS / 1000} s unless it reconnects`);
587
+ log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${limits.SESSION_GRACE_MS / 1000} s unless it reconnects`);
542
588
  session.ws = null;
543
589
  session.graceTimer = setTimeout(() => {
544
590
  if (agentSessions.get(sessionId) === session && !session.ws) endAgentSession(sessionId);
545
- }, LIMITS.SESSION_GRACE_MS);
591
+ }, limits.SESSION_GRACE_MS);
546
592
  }
547
593
  });
548
594
 
@@ -559,17 +605,27 @@ function createBridgeServer(options = {}) {
559
605
  // is accepted as it always was. A reconnect that omits it keeps the version
560
606
  // this profile reported before.
561
607
  const extensionVersion = typeof hello.extensionVersion === 'string' && hello.extensionVersion ? hello.extensionVersion.slice(0, 32) : null;
608
+ // Which build this is. Additive: a pre-0.8 extension sends nothing.
609
+ const extensionId = typeof hello.extensionId === 'string' && /^[a-p]{32}$/.test(hello.extensionId) ? hello.extensionId : null;
562
610
  const existing = profiles.get(id);
563
611
  if (existing && existing.ws !== ws && existing.ws.readyState === WebSocket.OPEN) {
564
612
  log('Browser profile reconnected (replacing old connection)');
565
613
  existing.ws.close(CLOSE_REPLACED, 'Replaced by new extension connection');
566
614
  }
567
- const label = sanitizeSessionName(hello.label) ?? existing?.label ?? `Profile ${profiles.size + (existing ? 0 : 1)}`;
568
- profiles.set(id, { ws, browser, label, connectedAt: Date.now(), extensionVersion: extensionVersion ?? existing?.extensionVersion ?? null });
615
+ const label = labels.connect(id, sanitizeSessionName(hello.label));
616
+ profiles.set(id, {
617
+ ws, browser, label, connectedAt: Date.now(),
618
+ extensionVersion: extensionVersion ?? existing?.extensionVersion ?? null,
619
+ extensionId: extensionId ?? existing?.extensionId ?? null,
620
+ });
569
621
  socketToProfile.set(ws, id);
570
622
  log(`Browser profile "${label}" connected (${browser}, ${id.slice(0, 8)}); ${profiles.size} connected`);
571
623
 
572
- safeSend(ws, { type: 'event', event: 'connected', data: { protocolVersion: PROTOCOL_VERSION, profile: { id, label }, profiles: profiles.size } });
624
+ safeSend(ws, {
625
+ type: 'event', event: 'connected',
626
+ data: { protocolVersion: PROTOCOL_VERSION, profile: { id, label }, profiles: profiles.size, profilesList: profileList(id) },
627
+ });
628
+ broadcastProfiles();
573
629
 
574
630
  // The built-in panel session exists in every profile; agent sessions only in the one they act in.
575
631
  for (const [sessionId, session] of agentSessions) {
@@ -588,6 +644,9 @@ function createBridgeServer(options = {}) {
588
644
  agentSessions.delete(sessionId);
589
645
  if (session.ws) socketToSession.delete(session.ws);
590
646
  log(`Agent "${session.name}" ended (session: ${sessionId})`);
647
+ // Anything the person sent to this agent and it never read is given back to "any agent" (feedback.releaseOrphans).
648
+ const released = feedback.releaseOrphans({ session: sessionId, liveSessionIds: new Set(agentSessions.keys()) });
649
+ if (released) log(`Released ${released} unread message(s) from "${session.name}"`);
591
650
  sendTo(profileSocket(session.profile), { type: 'session_end', session: sessionId });
592
651
  }
593
652
 
@@ -628,6 +687,15 @@ function createBridgeServer(options = {}) {
628
687
 
629
688
  // ─── Lifecycle ─────────────────────────────────────────────────────────────
630
689
 
690
+ // A killed bridge leaves messages addressed to sessions that no longer exist. No agent is live at start, so
691
+ // every undelivered stamp is released and the next agent to ask can read it (feedback.releaseOrphans).
692
+ try {
693
+ const swept = feedback.releaseOrphans({ liveSessionIds: new Set() });
694
+ if (swept) log(`Released ${swept} unread message(s) left by a previous run`);
695
+ } catch (err) {
696
+ log(`Could not release old messages: ${err.message}`);
697
+ }
698
+
631
699
  // Resolves once the listener is bound (or fails), so callers/tests do not race.
632
700
  const ready = new Promise((resolve, reject) => {
633
701
  wss.once('listening', () => {
@@ -621,7 +621,7 @@ async function setupCommand(args = [], { extensionPath = null, dir = configDir()
621
621
  write(' Warning: this copy runs from the npx cache, which npm may clear; Chrome and your agent would then');
622
622
  write(' lose BrowserZap. Install it once with `npm install -g browserzap`, then run `browserzap setup`.');
623
623
  }
624
- write(` Extension id: ${EXTENSION_ID}`);
624
+ write(` Extension id${dev ? 's' : ''}: ${dev ? `${STORE_EXTENSION_ID} (store), ${EXTENSION_ID} (developer build)` : STORE_EXTENSION_ID}`);
625
625
  write(` Extension mode: ${dev ? 'store + developer build (setup --dev)' : 'store only'}`);
626
626
  write(` Host manifest: ${result.manifestPaths[0]}`);
627
627
  write(` Launcher: ${result.launcher}`);
@@ -171,12 +171,31 @@ const MCP_TOOLS = [
171
171
  },
172
172
  },
173
173
  },
174
+ {
175
+ name: 'element_hover',
176
+ description:
177
+ 'Move the pointer over exactly one element and report what appeared (a menu, tooltip, or controls a row reveals ' +
178
+ 'on hover). Resolves the target like element_click and refuses ambiguous, hidden or covered targets. Read-only: ' +
179
+ 'no approval. Follow with element_click on what appeared.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ additionalProperties: false,
183
+ required: ['target'],
184
+ properties: {
185
+ target: TARGET,
186
+ timeoutMs: ACTION_TIMEOUT,
187
+ },
188
+ },
189
+ },
174
190
  {
175
191
  name: 'field_fill',
176
192
  description:
177
193
  'Replace the content of one text field (or set a checkbox/radio with "true"/"false") and verify it. ' +
178
- 'Sensitive fields (passwords, one-time codes, card numbers, government IDs) are refused with ' +
179
- 'USER_ACTION_REQUIRED — ask the user to type those. The value is never logged or echoed back.',
194
+ 'Text is typed through Chrome\'s trusted input channel at the caret, so key listeners fire and rich editors ' +
195
+ '(Medium, Gmail, LinkedIn, Slack) accept it. A multi-block rich editor is never cleared: the text is appended ' +
196
+ 'at the end of its last block and the result says mode "append". Sensitive fields (passwords, one-time codes, ' +
197
+ 'card numbers, government IDs) are refused with USER_ACTION_REQUIRED — ask the user to type those. The value is ' +
198
+ 'never logged or echoed back.',
180
199
  inputSchema: {
181
200
  type: 'object',
182
201
  additionalProperties: false,
@@ -248,16 +267,18 @@ const MCP_TOOLS = [
248
267
  {
249
268
  name: 'browser_press_key',
250
269
  description:
251
- 'Press one key on the focused element. Enter inside a form counts as submitting it and follows the same ' +
252
- 'approval rules as element_click.',
270
+ 'Press one key on the focused element: a named key (Enter, Tab, Escape, Backspace, Delete, the arrows, Home, ' +
271
+ 'End, PageUp, PageDown, Space), one printable character, or either with modifiers joined by "+" — ctrl+b, ' +
272
+ 'ctrl+k, ctrl+a, ctrl+alt+1, ctrl+enter, meta+shift+t. Enter (with or without modifiers) inside a form counts ' +
273
+ 'as submitting it and follows the same approval rules as element_click.',
253
274
  inputSchema: {
254
275
  type: 'object',
255
276
  additionalProperties: false,
256
277
  required: ['key'],
257
278
  properties: {
258
279
  key: {
259
- type: 'string',
260
- enum: ['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Space'],
280
+ type: 'string', minLength: 1, maxLength: 32,
281
+ description: 'A named key, one printable character, or a chord: ctrl+b, ctrl+enter, ctrl+alt+1.',
261
282
  },
262
283
  modifiers: { type: 'array', maxItems: 4, items: { type: 'string', enum: ['ctrl', 'alt', 'shift', 'meta'] } },
263
284
  approvalId: { type: 'string', maxLength: 64 },
@@ -542,6 +563,22 @@ const MCP_TOOLS = [
542
563
  },
543
564
  },
544
565
  },
566
+ {
567
+ name: 'feedback_wait',
568
+ description:
569
+ 'Listen for the person: wait until they send a message or notes from the BrowserZap side panel, then return it ' +
570
+ 'exactly like feedback_get (oldest first, marked delivered). Waits up to waitMs (default 60000, at most 300000) ' +
571
+ 'and says so if nothing came; the panel shows that you are listening meanwhile. Use it when the person asks you ' +
572
+ 'to listen to them in BrowserZap: wait, act or answer with chat_reply, then call feedback_wait again.',
573
+ inputSchema: {
574
+ type: 'object',
575
+ additionalProperties: false,
576
+ properties: {
577
+ waitMs: { type: 'integer', minimum: 1000, maximum: 300000 },
578
+ images: { type: 'boolean', description: 'Include the screenshots inline (default true).' },
579
+ },
580
+ },
581
+ },
545
582
  {
546
583
  name: 'page_console',
547
584
  description:
@@ -687,6 +724,7 @@ const ANNOTATIONS = {
687
724
  page_inspect: ['Inspect page', READ],
688
725
  browser_navigate: ['Navigate', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }],
689
726
  element_click: ['Click element', { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }],
727
+ element_hover: ['Hover element', READ],
690
728
  field_fill: ['Fill field', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }],
691
729
  select_option: ['Select option', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }],
692
730
  wait_for_state: ['Wait for page state', READ],
@@ -709,6 +747,7 @@ const ANNOTATIONS = {
709
747
  file_upload: ['Attach a file', { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }],
710
748
  feedback_list: ['List page comments', LOCAL_READ],
711
749
  feedback_get: ['Read page comments', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }],
750
+ feedback_wait: ['Listen for the person', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }],
712
751
  page_console: ['Read console', READ],
713
752
  page_network: ['Read network requests', READ],
714
753
  workflow_list: ['List workflows', LOCAL_READ],
@@ -728,7 +767,7 @@ for (const tool of MCP_TOOLS) {
728
767
  /** Tools handled entirely in the MCP server (no extension action). */
729
768
  const LOCAL_TOOLS = new Set([
730
769
  'browser_set_session_name', 'browser_list_profiles', 'browser_select_profile',
731
- 'feedback_list', 'feedback_get', 'browser_downloads',
770
+ 'feedback_list', 'feedback_get', 'feedback_wait', 'browser_downloads',
732
771
  'workflow_record', 'workflow_save', 'workflow_list', 'workflow_get', 'workflow_run',
733
772
  'workflow_delete', 'workflow_history', 'workflow_reports', 'workflow_status',
734
773
  ]);
@@ -739,6 +778,7 @@ const TOOL_TO_ACTION = {
739
778
  page_inspect: 'inspect',
740
779
  browser_navigate: 'navigate',
741
780
  element_click: 'click',
781
+ element_hover: 'hover',
742
782
  field_fill: 'fill',
743
783
  select_option: 'select_option',
744
784
  wait_for_state: 'wait_for',
@@ -765,7 +805,7 @@ const INTERNAL_ACTIONS = new Set(['notify']);
765
805
 
766
806
  /** Tools that act on page content and therefore carry the origin allowlist. */
767
807
  const PAGE_TOOLS = new Set([
768
- 'page_inspect', 'browser_navigate', 'element_click', 'field_fill', 'select_option',
808
+ 'page_inspect', 'browser_navigate', 'element_click', 'element_hover', 'field_fill', 'select_option',
769
809
  'wait_for_state', 'screenshot_capture', 'browser_press_key', 'browser_scroll',
770
810
  ]);
771
811
 
@@ -830,11 +870,11 @@ function validateArguments(tool, args) {
830
870
  }
831
871
 
832
872
  /**
833
- * Tools held back for internal testing (0.8). They are offered only when
834
- * BROWSERZAP_EXPERIMENTAL lists their feature, e.g. BROWSERZAP_EXPERIMENTAL=chat.
835
- * A hidden tool is also refused if called (mcp-server.js looks tools up here).
873
+ * Tools held back for internal testing. They are offered only when BROWSERZAP_EXPERIMENTAL lists their feature,
874
+ * e.g. BROWSERZAP_EXPERIMENTAL=chat. A hidden tool is also refused if called (mcp-server.js looks tools up here).
875
+ * Empty since 0.8.0: chat_reply and feedback_wait ship (docs/DECISIONS.md D-0.8-2).
836
876
  */
837
- const EXPERIMENTAL_TOOLS = Object.freeze({ chat_reply: 'chat' });
877
+ const EXPERIMENTAL_TOOLS = Object.freeze({});
838
878
  const enabledExperiments = new Set(String(process.env.BROWSERZAP_EXPERIMENTAL ?? '').split(',').map((s) => s.trim()).filter(Boolean));
839
879
  for (let i = MCP_TOOLS.length - 1; i >= 0; i--) {
840
880
  const feature = EXPERIMENTAL_TOOLS[MCP_TOOLS[i].name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browserzap",
3
- "version": "0.7.0",
3
+ "version": "0.8.5",
4
4
  "description": "Let your AI agent (Claude Code, Codex, Cursor or any MCP client) work in your own Chrome, with your logins, your approval and a Stop button.",
5
5
  "homepage": "https://browserzap.com",
6
6
  "bugs": {
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node scripts/clean.mjs --dist-only && webpack --mode production && npm run build:css && npm run package:extension",
45
+ "build:e2e": "webpack --mode production --env e2e && npm run build:css:e2e && node scripts/package-extension.mjs --from dist/chrome-e2e --dest extension-e2e",
45
46
  "dev": "webpack --mode development --watch",
46
47
  "clean": "node scripts/clean.mjs",
47
48
  "package:extension": "node scripts/package-extension.mjs",
@@ -56,13 +57,16 @@
56
57
  "license:scan": "node scripts/license-scan.mjs",
57
58
  "verify": "npm run lint && npm run typecheck && npm run test",
58
59
  "release:check": "node scripts/release-check.mjs",
59
- "test:e2e": "node scripts/run-tests.mjs tests/e2e .e2e.mjs --test-concurrency=1",
60
+ "test:e2e": "node scripts/run-tests.mjs tests/e2e .e2e.mjs --test-concurrency=1 --e2e-limits",
61
+ "test:e2e:fast": "node scripts/run-tests.mjs tests/e2e .e2e.mjs --test-concurrency=1 --e2e-limits --fast",
62
+ "test:e2e:real-timers": "node scripts/run-tests.mjs tests/e2e .e2e.mjs --test-concurrency=1 --real-timers",
60
63
  "config": "node scripts/mcp-config.mjs",
61
64
  "test:clients": "node tests/e2e/clients.mjs",
62
65
  "benchmark": "node tests/e2e/benchmark.mjs",
63
66
  "demo": "node tests/e2e/demo.mjs",
64
67
  "compare:duplicates": "node tests/e2e/duplicates.mjs",
65
68
  "build:css": "tailwindcss -i src/sidepanel/sidepanel.css -o dist/chrome/sidepanel.css --minify",
69
+ "build:css:e2e": "tailwindcss -i src/sidepanel/sidepanel.css -o dist/chrome-e2e/sidepanel.css --minify",
66
70
  "dev:css": "tailwindcss -i src/sidepanel/sidepanel.css -o dist/chrome/sidepanel.css --watch",
67
71
  "logs": "node bridge-server/index.js --logs",
68
72
  "runs": "node bridge-server/index.js --runs",
@@ -71,8 +75,13 @@
71
75
  "build:reports": "node scripts/build-reports.mjs",
72
76
  "build:brand": "node scripts/build-brand.mjs",
73
77
  "build:site": "npm --prefix web run build",
74
- "deploy:site": "npm --prefix web run build && wrangler pages deploy web/out --project-name browserzap --branch main",
75
- "bridge:restart": "node scripts/bridge-restart.mjs"
78
+ "deploy:site": "npm --prefix web run deploy",
79
+ "bridge:restart": "node scripts/bridge-restart.mjs",
80
+ "site:data": "npm --prefix web run data --",
81
+ "test:e2e:rerun-failed": "node scripts/run-tests.mjs tests/e2e .e2e.mjs --test-concurrency=1 --e2e-limits --rerun-failures",
82
+ "release:changes": "node scripts/changes-since.mjs",
83
+ "release:public": "node scripts/release-public.mjs",
84
+ "release:internal": "node scripts/release-internal.mjs"
76
85
  },
77
86
  "devDependencies": {
78
87
  "@fontsource-variable/inter": "5.3.0",
@@ -80,15 +89,17 @@
80
89
  "@resvg/resvg-js": "2.6.2",
81
90
  "@tailwindcss/cli": "4.3.0",
82
91
  "@types/chrome": "^0.1.38",
92
+ "axe-core": "^4.10.2",
83
93
  "copy-webpack-plugin": "^14.0.0",
84
94
  "css-loader": "^7.1.2",
95
+ "lucide-preact": "^1.66.0",
96
+ "preact": "^10.29.8",
85
97
  "style-loader": "^4.0.0",
86
98
  "tailwindcss": "4.3.0",
87
99
  "ts-loader": "^9.5.1",
88
100
  "typescript": "^5.6.3",
89
101
  "webpack": "^5.96.1",
90
- "webpack-cli": "^5.1.4",
91
- "preact": "^10.29.8"
102
+ "webpack-cli": "^5.1.4"
92
103
  },
93
104
  "dependencies": {
94
105
  "ws": "^8.22.0"