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.
@@ -22,11 +22,11 @@
22
22
  * closes or after a bounded wait with no request.
23
23
  */
24
24
 
25
- const { getOrCreateSecret } = require('./secret');
25
+ const { getOrCreateSecret, configDir } = require('./secret');
26
26
  const { DEFAULT_PORT, PROTOCOL_VERSION, fileLogger } = require('./server');
27
27
  const { ensureBridge } = require('./ensure-bridge');
28
28
  const { helloProbe } = require('./probe');
29
- const { HOST_NAME, allowedOrigins } = require('./extension-id');
29
+ const { HOST_NAME, allowedOrigins, allowedExtensionIds } = require('./extension-id');
30
30
 
31
31
  /** Inbound frames above this are rejected before parsing. Chrome's own host→Chrome cap is 1 MiB. */
32
32
  const MAX_MESSAGE_BYTES = 1024 * 1024;
@@ -139,11 +139,29 @@ async function handleRequest(request, options = {}) {
139
139
  return { type: 'hello', port, token, protocolVersion: PROTOCOL_VERSION };
140
140
  }
141
141
 
142
+ /**
143
+ * Whether Chrome's reported caller origin may start this helper.
144
+ *
145
+ * Defense in depth: allowed_origins in the host manifest is the real control.
146
+ * Chrome may pass no origin at all, and anything that is not a
147
+ * chrome-extension:// origin is not ours to judge here. The set of extension
148
+ * ids comes from `dir` (store id only, plus the unpacked id after
149
+ * `browserzap setup --dev`), so it is the same answer `setup` wrote.
150
+ */
151
+ function isAllowedOrigin(origin, { dir } = {}) {
152
+ if (typeof origin !== 'string' || !origin.startsWith('chrome-extension://')) return true;
153
+ try {
154
+ return allowedOrigins(allowedExtensionIds({ dir })).includes(origin);
155
+ } catch {
156
+ return false; // a config dir we cannot read allows nothing beyond the manifest
157
+ }
158
+ }
159
+
142
160
  function main() {
143
161
  // Defense in depth: allowed_origins is the real control, but refuse a caller
144
162
  // that is not the BrowserZap extension if Chrome tells us who it is.
145
163
  const origin = process.argv[2];
146
- if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
164
+ if (!isAllowedOrigin(origin, { dir: configDir() })) {
147
165
  logLine('wrong_origin');
148
166
  process.exit(1);
149
167
  }
@@ -204,4 +222,5 @@ module.exports = {
204
222
  decodeMessage,
205
223
  handleRequest,
206
224
  configuredPort,
225
+ isAllowedOrigin,
207
226
  };
@@ -73,9 +73,10 @@ function writeLastRuns(runs) {
73
73
  * @param {object} io
74
74
  * @param {(msg: object, socket: object, profile: string) => boolean} io.deliver send an action for the panel session to that profile; false if it is not connected
75
75
  * @param {(msg: object, profile: string) => void} io.toExtension send a panel_event / cancel to that profile's extension
76
+ * @param {(session: string | null) => void} [io.onFeedback] a batch was saved for this session (null: any)
76
77
  * @param {(event: string, fields?: object) => void} [io.log]
77
78
  */
78
- function createPanelRunner({ deliver, toExtension, log = () => {} }) {
79
+ function createPanelRunner({ deliver, toExtension, isSessionLive = () => true, onFeedback = () => {}, log = () => {} }) {
79
80
  const service = createWorkflowService({ log });
80
81
  const sessionLog = createSessionLog({ session: PANEL_SESSION, client: () => 'side panel' });
81
82
  let current = null;
@@ -438,9 +439,14 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
438
439
  return { ok: true, data: {} };
439
440
  case 'feedback_submit': {
440
441
  // Comment mode (docs/COMMENT_MODE.md): stored on this device for the agent's feedback_get.
441
- const saved = feedback.saveBatch(args.batch);
442
- log('feedback_saved', { batch: saved.id, comments: saved.count });
443
- return { ok: true, data: { batchId: saved.id, count: saved.count } };
442
+ // Addressed to an agent that has gone? Then "any agent" (session null), never an id nobody can read.
443
+ const asked = args.batch && typeof args.batch.session === 'string' ? args.batch.session : null;
444
+ const live = asked ? isSessionLive(asked) : false;
445
+ const saved = feedback.saveBatch(live ? args.batch : { ...args.batch, session: null });
446
+ log('feedback_saved', { batch: saved.id, comments: saved.count, live });
447
+ // Wake a waiting feedback_wait now instead of on its next poll.
448
+ onFeedback(live ? asked : null);
449
+ return { ok: true, data: { batchId: saved.id, count: saved.count, session: live ? asked : null, live } };
444
450
  }
445
451
  default:
446
452
  return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Unknown panel request "${op}".` } };
@@ -82,6 +82,61 @@ function helloProbe({ port, token, session, name, timeoutMs = 3000 }) {
82
82
  });
83
83
  }
84
84
 
85
+ /**
86
+ * Ask the bridge which Chrome profiles are connected (`browserzap doctor`).
87
+ *
88
+ * Connects like helloProbe, then sends the bridge's own `profiles_list`
89
+ * request (answered by server.js with `{ type: 'bridge_reply', id, ok, data }`)
90
+ * and ends its session on close as helloProbe does, so no session lingers.
91
+ * Resolves { ok: true, profiles } or { ok: false, profiles: [] }. Never logs
92
+ * the token.
93
+ */
94
+ function listProfiles({ port, token, session, name, timeoutMs = 2000 }) {
95
+ const requestId = 'doctor-profiles';
96
+ return new Promise((resolve) => {
97
+ const ws = new WebSocket(`ws://${BIND_HOST}:${port}`);
98
+ let settled = false;
99
+ const finish = (result, { gracefully = false } = {}) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ try {
104
+ if (gracefully) ws.close();
105
+ else ws.terminate();
106
+ } catch { /* already closed */ }
107
+ resolve(result);
108
+ };
109
+ const timer = setTimeout(() => finish({ ok: false, profiles: [] }), timeoutMs);
110
+ ws.on('open', () => {
111
+ ws.send(JSON.stringify({ type: 'hello', role: 'agent', token: token ?? '', protocolVersion: PROTOCOL_VERSION, session, name }));
112
+ });
113
+ ws.on('message', (data) => {
114
+ let msg;
115
+ try {
116
+ msg = JSON.parse(String(data));
117
+ } catch {
118
+ return; // the timer covers a stuck handshake
119
+ }
120
+ if (msg.type === 'session_started') {
121
+ // Same rule as helloProbe: a probe's session ends with its socket.
122
+ const close = ws.close.bind(ws);
123
+ ws.close = (...closeArgs) => {
124
+ try { ws.send(JSON.stringify({ type: 'session_end', session: msg.session })); } catch { /* closing anyway */ }
125
+ close(...closeArgs);
126
+ };
127
+ ws.send(JSON.stringify({ type: 'profiles_list', id: requestId }));
128
+ return;
129
+ }
130
+ if (msg.type === 'bridge_reply' && msg.id === requestId) {
131
+ const profiles = msg.data?.profiles ?? msg.evidence?.profiles;
132
+ finish(Array.isArray(profiles) ? { ok: true, profiles } : { ok: false, profiles: [] }, { gracefully: true });
133
+ }
134
+ });
135
+ ws.on('close', () => finish({ ok: false, profiles: [] }));
136
+ ws.on('error', () => finish({ ok: false, profiles: [] }));
137
+ });
138
+ }
139
+
85
140
  /** The protocol a refusing bridge says it speaks ("Version mismatch: this bridge speaks protocol 2, …"), or null. */
86
141
  function bridgeProtocolFrom(reason) {
87
142
  const m = /speaks protocol (\d+)/.exec(String(reason || ''));
@@ -121,4 +176,4 @@ async function stopBridgeOnPort(port) {
121
176
  return pids;
122
177
  }
123
178
 
124
- module.exports = { isListening, helloProbe, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
179
+ module.exports = { isListening, helloProbe, listProfiles, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stable names for connected browser profiles, kept per extension install id.
5
+ *
6
+ * An unnamed profile gets "Profile N" once and keeps it across reconnects and bridge restarts; the number is
7
+ * never handed to another install. A rename changes only that install. Names live in `profiles.json` in the
8
+ * config folder (no page data, only id → label). Without a file (tests) they live in memory.
9
+ */
10
+
11
+ const fs = require('fs');
12
+
13
+ const MAX_ENTRIES = 50;
14
+
15
+ function createProfileLabels(file = null) {
16
+ /** @type {Record<string, { label: string, custom: boolean, number?: number, seen: number }>} */
17
+ let entries = {};
18
+ if (file) {
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entries = parsed;
22
+ } catch {
23
+ /* first run or unreadable: start empty */
24
+ }
25
+ }
26
+
27
+ function save() {
28
+ const ids = Object.keys(entries);
29
+ if (ids.length > MAX_ENTRIES) {
30
+ ids.sort((a, b) => entries[a].seen - entries[b].seen).slice(0, ids.length - MAX_ENTRIES).forEach((id) => delete entries[id]);
31
+ }
32
+ if (!file) return;
33
+ try {
34
+ fs.writeFileSync(file, JSON.stringify(entries, null, 2), { mode: 0o600 });
35
+ } catch {
36
+ /* names still work for this run */
37
+ }
38
+ }
39
+
40
+ function nextNumber() {
41
+ let max = 0;
42
+ for (const entry of Object.values(entries)) if (typeof entry.number === 'number' && entry.number > max) max = entry.number;
43
+ return max + 1;
44
+ }
45
+
46
+ return {
47
+ /** The name for an install that just connected: what it sent, else what it had, else a new "Profile N". */
48
+ connect(id, sentLabel) {
49
+ const entry = entries[id];
50
+ if (sentLabel) {
51
+ // A named install takes no number: "Profile N" counts unnamed ones. It keeps one it already had.
52
+ entries[id] = { ...entry, label: sentLabel, custom: true, seen: Date.now() };
53
+ } else if (entry) {
54
+ entry.seen = Date.now();
55
+ } else {
56
+ const number = nextNumber();
57
+ entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
58
+ }
59
+ save();
60
+ return entries[id].label;
61
+ },
62
+ /** The person renamed this install; no other entry changes. */
63
+ rename(id, label) {
64
+ entries[id] = { ...entries[id], label, custom: true, seen: Date.now() };
65
+ save();
66
+ return label;
67
+ },
68
+ /** The person cleared the name: back to this install's own "Profile N". */
69
+ reset(id) {
70
+ const number = entries[id]?.number ?? nextNumber();
71
+ entries[id] = { label: `Profile ${number}`, custom: false, number, seen: Date.now() };
72
+ save();
73
+ return entries[id].label;
74
+ },
75
+ get(id) {
76
+ return entries[id]?.label ?? null;
77
+ },
78
+ };
79
+ }
80
+
81
+ module.exports = { createProfileLabels };
@@ -12,7 +12,7 @@ const { LIMITS } = require('./limits');
12
12
  * step — so every wait and every retry still goes through the normal tool path
13
13
  * and extension policy.
14
14
  *
15
- * Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
15
+ * Design: docs/archive/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
16
16
  * the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
17
17
  *
18
18
  * Adding a resolver is one entry here plus a fixture that fails without it.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The bridge WebSocket is the only network listener in the system. Upstream
5
5
  * shipped it bound to 0.0.0.0 with no authentication at all (finding SEC-01 in
6
- * docs/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
6
+ * docs/archive/REPOSITORY_AUDIT.md), which let any local or LAN process drive the
7
7
  * user's logged-in browser.
8
8
  *
9
9
  * BrowserZap closes that by requiring a shared secret on the first frame of every
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Derived from Open Browser Control (MIT, Copyright (c) 2026 smankoo) at commit
11
11
  * 4e6c262. Changes from upstream are marked "// BROWSERZAP:" and are summarised in
12
- * docs/PHASE_1_REPORT.md:
12
+ * docs/archive/PHASE_1_REPORT.md:
13
13
  *
14
14
  * - the listener binds 127.0.0.1 only and refuses to run otherwise (SEC-01)
15
15
  * - every connection must authenticate with the per-install secret before it
@@ -36,6 +36,8 @@ const { getOrCreateSecret, secretMatches, secretPath, configDir } = require('./s
36
36
  const pairing = require('./pairing');
37
37
  const { version: BRIDGE_VERSION } = require('../package.json');
38
38
  const { createPanelRunner } = require('./panel');
39
+ const feedback = require('./feedback');
40
+ const { createProfileLabels } = require('./profile-labels');
39
41
  const { LIMITS } = require('./limits');
40
42
 
41
43
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -74,7 +76,7 @@ const VALID_AGENT_ACTIONS = new Set([
74
76
  'status', 'inspect', 'navigate', 'click', '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
- 'console', 'network',
79
+ 'console', 'network', 'say',
78
80
  ]);
79
81
 
80
82
  const VALID_MESSAGE_TYPES = new Set([
@@ -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
 
@@ -157,13 +163,21 @@ function createBridgeServer(options = {}) {
157
163
  * Connected browser profiles. Every Chrome profile has its own copy of the
158
164
  * extension (its own instance id, tabs and logins), and each may connect
159
165
  * once (multi-profile, docs/MULTI_PROFILE.md). The same install reconnecting
160
- * replaces its own socket.
161
- * @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number }>}
166
+ * replaces its own socket. `extensionVersion` is what the extension reported
167
+ * in its hello (WP5); null when it is older and reports nothing.
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 }>}
162
171
  */
163
172
  const profiles = new Map();
164
173
  /** @type {Map<WebSocket, string>} */
165
174
  const socketToProfile = new Map();
166
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')));
167
181
 
168
182
  /**
169
183
  * Agent sessions. `profile` is the browser profile the session acts in: set
@@ -183,8 +197,21 @@ function createBridgeServer(options = {}) {
183
197
  };
184
198
  const profileList = (selected) => [...profiles.entries()].map(([id, p]) => ({
185
199
  id, label: p.label, browser: p.browser, connectedAt: new Date(p.connectedAt).toISOString(), selected: id === selected,
200
+ // What the build said in its hello; null for a build older than 0.7.0 / 0.8.0.
201
+ extensionVersion: p.extensionVersion ?? null,
202
+ extensionId: p.extensionId ?? null,
186
203
  }));
187
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
+
188
215
  // The side panel's workflow runner: a built-in session that drives the
189
216
  // extension like an agent would, so every step gets the same checks. A run
190
217
  // acts in the profile whose side panel started it.
@@ -196,6 +223,19 @@ function createBridgeServer(options = {}) {
196
223
  return true;
197
224
  },
198
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
+ },
199
239
  log: (event, fields) => log(`panel ${event}${fields ? ` ${JSON.stringify(fields)}` : ''}`),
200
240
  });
201
241
  if (panel) agentSessions.set(panel.session, { ws: panel.socket, name: panel.sessionName, profile: null, builtIn: true });
@@ -431,9 +471,11 @@ function createBridgeServer(options = {}) {
431
471
  if (msg.type === 'profile_update') {
432
472
  const label = sanitizeSessionName(msg.label);
433
473
  const p = profiles.get(fromProfile);
434
- if (p && label) {
435
- p.label = label;
436
- log(`Profile renamed to "${label}"`);
474
+ if (p) {
475
+ // An empty name gives this install back its own "Profile N"; no other profile changes.
476
+ p.label = label ? labels.rename(fromProfile, label) : labels.reset(fromProfile);
477
+ log(`Profile renamed to "${p.label}"`);
478
+ broadcastProfiles();
437
479
  }
438
480
  return;
439
481
  }
@@ -467,13 +509,19 @@ function createBridgeServer(options = {}) {
467
509
  if (msg.type === 'session_update') {
468
510
  const session = agentSessions.get(sessionId);
469
511
  const name = sanitizeSessionName(msg.name);
470
- if (session && name) {
512
+ const listening = typeof msg.listening === 'boolean' ? msg.listening : undefined;
513
+ // The tab a reply belongs to when the agent read a message from a tab no session owned (updateChatTab).
514
+ const chatTabId = Number.isInteger(msg.chatTabId) && msg.chatTabId >= 0 ? msg.chatTabId : undefined;
515
+ if (!session || (!name && listening === undefined && chatTabId === undefined)) return;
516
+ if (name) {
471
517
  session.name = name;
472
- msg.name = name;
473
518
  log(`Agent "${session.name}" renamed (session: ${sessionId})`);
474
- } else {
475
- return;
476
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;
477
525
  }
478
526
  // The bridge, not the agent, decides which session a message belongs to.
479
527
  msg.session = sessionId;
@@ -524,6 +572,7 @@ function createBridgeServer(options = {}) {
524
572
  if (profiles.get(profileId)?.ws === ws) {
525
573
  log(`Browser profile "${profiles.get(profileId).label}" disconnected`);
526
574
  profiles.delete(profileId);
575
+ broadcastProfiles();
527
576
  }
528
577
  return;
529
578
  }
@@ -535,11 +584,11 @@ function createBridgeServer(options = {}) {
535
584
  // The connection dropped without a goodbye: the agent may come back with
536
585
  // the same session id (a restart, a blip), so its tabs wait
537
586
  // SESSION_GRACE_MS before the session ends (D-38).
538
- log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${LIMITS.SESSION_GRACE_MS / 1000} s unless it reconnects`);
587
+ log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${limits.SESSION_GRACE_MS / 1000} s unless it reconnects`);
539
588
  session.ws = null;
540
589
  session.graceTimer = setTimeout(() => {
541
590
  if (agentSessions.get(sessionId) === session && !session.ws) endAgentSession(sessionId);
542
- }, LIMITS.SESSION_GRACE_MS);
591
+ }, limits.SESSION_GRACE_MS);
543
592
  }
544
593
  });
545
594
 
@@ -552,17 +601,31 @@ function createBridgeServer(options = {}) {
552
601
  // came from auto-pairing or from a pasted token (ADR-009).
553
602
  pairing.recordPairing(id);
554
603
  const browser = typeof hello.browser === 'string' ? hello.browser : 'unknown';
604
+ // Additive hello field (WP5): a build older than 0.7.0 sends nothing, which
605
+ // is accepted as it always was. A reconnect that omits it keeps the version
606
+ // this profile reported before.
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;
555
610
  const existing = profiles.get(id);
556
611
  if (existing && existing.ws !== ws && existing.ws.readyState === WebSocket.OPEN) {
557
612
  log('Browser profile reconnected (replacing old connection)');
558
613
  existing.ws.close(CLOSE_REPLACED, 'Replaced by new extension connection');
559
614
  }
560
- const label = sanitizeSessionName(hello.label) ?? existing?.label ?? `Profile ${profiles.size + (existing ? 0 : 1)}`;
561
- profiles.set(id, { ws, browser, label, connectedAt: Date.now() });
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
+ });
562
621
  socketToProfile.set(ws, id);
563
622
  log(`Browser profile "${label}" connected (${browser}, ${id.slice(0, 8)}); ${profiles.size} connected`);
564
623
 
565
- safeSend(ws, { type: 'event', event: 'connected', data: { protocolVersion: PROTOCOL_VERSION, profile: { id, label }, profiles: profiles.size } });
624
+ safeSend(ws, {
625
+ type: 'event', event: 'connected',
626
+ data: { protocolVersion: PROTOCOL_VERSION, profile: { id, label }, profiles: profiles.size, profilesList: profileList(id) },
627
+ });
628
+ broadcastProfiles();
566
629
 
567
630
  // The built-in panel session exists in every profile; agent sessions only in the one they act in.
568
631
  for (const [sessionId, session] of agentSessions) {
@@ -581,6 +644,9 @@ function createBridgeServer(options = {}) {
581
644
  agentSessions.delete(sessionId);
582
645
  if (session.ws) socketToSession.delete(session.ws);
583
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}"`);
584
650
  sendTo(profileSocket(session.profile), { type: 'session_end', session: sessionId });
585
651
  }
586
652
 
@@ -621,6 +687,15 @@ function createBridgeServer(options = {}) {
621
687
 
622
688
  // ─── Lifecycle ─────────────────────────────────────────────────────────────
623
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
+
624
699
  // Resolves once the listener is bound (or fails), so callers/tests do not race.
625
700
  const ready = new Promise((resolve, reject) => {
626
701
  wss.once('listening', () => {