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 +226 -2
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/bridge-server/background-runs.js +1 -1
- package/bridge-server/entitlements.js +8 -6
- package/bridge-server/feedback.js +43 -12
- package/bridge-server/limits.js +20 -1
- package/bridge-server/mcp-server.js +89 -14
- package/bridge-server/panel.js +10 -4
- package/bridge-server/profile-labels.js +81 -0
- package/bridge-server/resolvers.js +1 -1
- package/bridge-server/secret.js +1 -1
- package/bridge-server/server.js +84 -16
- package/bridge-server/setup.js +1 -1
- package/bridge-server/tools.js +52 -12
- package/package.json +17 -6
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.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
6
|
-
* Pro adds workflow history, bulk runs
|
|
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
|
-
/**
|
|
40
|
-
|
|
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':
|
|
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
|
-
|
|
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
|
-
/**
|
|
201
|
-
function
|
|
202
|
-
return
|
|
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 };
|
package/bridge-server/limits.js
CHANGED
|
@@ -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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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.
|
|
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
|
});
|
package/bridge-server/panel.js
CHANGED
|
@@ -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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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.
|
package/bridge-server/secret.js
CHANGED
|
@@ -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
|
package/bridge-server/server.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
},
|
|
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)
|
|
568
|
-
profiles.set(id, {
|
|
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, {
|
|
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', () => {
|
package/bridge-server/setup.js
CHANGED
|
@@ -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}`);
|
package/bridge-server/tools.js
CHANGED
|
@@ -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
|
-
'
|
|
179
|
-
'
|
|
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
|
|
252
|
-
'
|
|
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
|
-
|
|
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
|
|
834
|
-
*
|
|
835
|
-
*
|
|
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({
|
|
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.
|
|
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
|
|
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"
|