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