browserzap 0.4.0 → 0.5.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.
@@ -1,130 +1,130 @@
1
- /**
2
- * BrowserZap — screenshots kept on this device, with retention (docs/AUDIT.md AU-18).
3
- *
4
- * A screenshot is an image of the user's own page (mail, banking, admin
5
- * screens), so it never leaves the machine and is not kept forever:
6
- *
7
- * <config dir>/screenshots/screenshot-<time>-<id>.jpg (folder 0700, files 0600)
8
- *
9
- * - kept BROWSERZAP_SCREENSHOT_DAYS days (default 7, 0 = deleted when the MCP
10
- * server exits) and at most MAX_FILES files; the oldest go first
11
- * - pruned when the MCP server starts and after each new screenshot
12
- * - the per-session %TEMP%\browserzap-screenshots-* folders that releases up
13
- * to 0.3.0 left behind are cleaned with the same retention
14
- *
15
- * `node bridge-server/index.js --screenshots` shows the folder and what it holds.
16
- */
17
-
18
- 'use strict';
19
-
20
- const fs = require('fs');
21
- const os = require('os');
22
- const path = require('path');
23
- const crypto = require('crypto');
24
-
25
- const { configDir } = require('./secret');
26
-
27
- const DEFAULT_KEEP_DAYS = 7;
28
- const MAX_FILES = 200;
29
- const LEGACY_PREFIX = 'browserzap-screenshots-';
30
- const FILE_RE = /^screenshot-\d+-[0-9a-f]{8}\.(jpg|png)$/;
31
-
32
- const screenshotDir = () => path.join(configDir(), 'screenshots');
33
-
34
- /** Days to keep screenshots: BROWSERZAP_SCREENSHOT_DAYS (0–365), default 7. */
35
- function keepDays() {
36
- const raw = process.env.BROWSERZAP_SCREENSHOT_DAYS;
37
- if (raw === undefined || raw === '') return DEFAULT_KEEP_DAYS;
38
- const n = Number(raw);
39
- return Number.isFinite(n) && n >= 0 && n <= 365 ? n : DEFAULT_KEEP_DAYS;
40
- }
41
-
42
- function listShots(dir) {
43
- try {
44
- return fs.readdirSync(dir)
45
- .filter((f) => FILE_RE.test(f))
46
- .map((f) => {
47
- const file = path.join(dir, f);
48
- try {
49
- return { file, mtime: fs.statSync(file).mtimeMs };
50
- } catch {
51
- return null;
52
- }
53
- })
54
- .filter(Boolean)
55
- .sort((a, b) => a.mtime - b.mtime);
56
- } catch {
57
- return [];
58
- }
59
- }
60
-
61
- function remove(file) {
62
- try {
63
- fs.unlinkSync(file);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- /**
71
- * Delete screenshots older than the retention, and the oldest beyond MAX_FILES.
72
- * Never throws. Returns how many files were removed.
73
- */
74
- function prune({ now = Date.now(), days = keepDays(), legacy = true } = {}) {
75
- const cutoff = now - days * 86_400_000;
76
- let removed = 0;
77
- const shots = listShots(screenshotDir());
78
- const keep = [];
79
- for (const shot of shots) {
80
- if (shot.mtime < cutoff && remove(shot.file)) removed++;
81
- else keep.push(shot);
82
- }
83
- while (keep.length > MAX_FILES) if (remove(keep.shift().file)) removed++;
84
-
85
- if (legacy) {
86
- let entries = [];
87
- try {
88
- entries = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith(LEGACY_PREFIX));
89
- } catch { /* no temp listing */ }
90
- for (const name of entries) {
91
- const dir = path.join(os.tmpdir(), name);
92
- for (const shot of listShots(dir)) if (shot.mtime < cutoff && remove(shot.file)) removed++;
93
- try {
94
- if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
95
- } catch { /* in use or gone */ }
96
- }
97
- }
98
- return removed;
99
- }
100
-
101
- /** Write one screenshot and return its path. */
102
- function save(base64Data, format) {
103
- const dir = screenshotDir();
104
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
- const filename = `screenshot-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${format === 'jpeg' ? 'jpg' : 'png'}`;
106
- const file = path.join(dir, filename);
107
- fs.writeFileSync(file, Buffer.from(base64Data, 'base64'), { mode: 0o600 });
108
- // With 0 days the files live until the MCP server exits (pruneOnExit).
109
- if (keepDays() > 0) prune({ legacy: false });
110
- return file;
111
- }
112
-
113
- /** What the folder holds, for --screenshots and browser_status. */
114
- function summary() {
115
- const shots = listShots(screenshotDir());
116
- let bytes = 0;
117
- for (const s of shots) {
118
- try {
119
- bytes += fs.statSync(s.file).size;
120
- } catch { /* gone */ }
121
- }
122
- return { folder: screenshotDir(), files: shots.length, bytes, keepDays: keepDays(), maxFiles: MAX_FILES };
123
- }
124
-
125
- /** With BROWSERZAP_SCREENSHOT_DAYS=0, delete every screenshot when the MCP server exits. */
126
- function pruneOnExit() {
127
- if (keepDays() === 0) prune({ days: 0, now: Date.now() + 1000, legacy: false });
128
- }
129
-
130
- module.exports = { save, prune, pruneOnExit, summary, screenshotDir, keepDays, MAX_FILES };
1
+ /**
2
+ * BrowserZap — screenshots kept on this device, with retention (docs/AUDIT.md AU-18).
3
+ *
4
+ * A screenshot is an image of the user's own page (mail, banking, admin
5
+ * screens), so it never leaves the machine and is not kept forever:
6
+ *
7
+ * <config dir>/screenshots/screenshot-<time>-<id>.jpg (folder 0700, files 0600)
8
+ *
9
+ * - kept BROWSERZAP_SCREENSHOT_DAYS days (default 7, 0 = deleted when the MCP
10
+ * server exits) and at most MAX_FILES files; the oldest go first
11
+ * - pruned when the MCP server starts and after each new screenshot
12
+ * - the per-session %TEMP%\browserzap-screenshots-* folders that releases up
13
+ * to 0.3.0 left behind are cleaned with the same retention
14
+ *
15
+ * `node bridge-server/index.js --screenshots` shows the folder and what it holds.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+
25
+ const { configDir } = require('./secret');
26
+
27
+ const DEFAULT_KEEP_DAYS = 7;
28
+ const MAX_FILES = 200;
29
+ const LEGACY_PREFIX = 'browserzap-screenshots-';
30
+ const FILE_RE = /^screenshot-\d+-[0-9a-f]{8}\.(jpg|png)$/;
31
+
32
+ const screenshotDir = () => path.join(configDir(), 'screenshots');
33
+
34
+ /** Days to keep screenshots: BROWSERZAP_SCREENSHOT_DAYS (0–365), default 7. */
35
+ function keepDays() {
36
+ const raw = process.env.BROWSERZAP_SCREENSHOT_DAYS;
37
+ if (raw === undefined || raw === '') return DEFAULT_KEEP_DAYS;
38
+ const n = Number(raw);
39
+ return Number.isFinite(n) && n >= 0 && n <= 365 ? n : DEFAULT_KEEP_DAYS;
40
+ }
41
+
42
+ function listShots(dir) {
43
+ try {
44
+ return fs.readdirSync(dir)
45
+ .filter((f) => FILE_RE.test(f))
46
+ .map((f) => {
47
+ const file = path.join(dir, f);
48
+ try {
49
+ return { file, mtime: fs.statSync(file).mtimeMs };
50
+ } catch {
51
+ return null;
52
+ }
53
+ })
54
+ .filter(Boolean)
55
+ .sort((a, b) => a.mtime - b.mtime);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function remove(file) {
62
+ try {
63
+ fs.unlinkSync(file);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Delete screenshots older than the retention, and the oldest beyond MAX_FILES.
72
+ * Never throws. Returns how many files were removed.
73
+ */
74
+ function prune({ now = Date.now(), days = keepDays(), legacy = true } = {}) {
75
+ const cutoff = now - days * 86_400_000;
76
+ let removed = 0;
77
+ const shots = listShots(screenshotDir());
78
+ const keep = [];
79
+ for (const shot of shots) {
80
+ if (shot.mtime < cutoff && remove(shot.file)) removed++;
81
+ else keep.push(shot);
82
+ }
83
+ while (keep.length > MAX_FILES) if (remove(keep.shift().file)) removed++;
84
+
85
+ if (legacy) {
86
+ let entries = [];
87
+ try {
88
+ entries = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith(LEGACY_PREFIX));
89
+ } catch { /* no temp listing */ }
90
+ for (const name of entries) {
91
+ const dir = path.join(os.tmpdir(), name);
92
+ for (const shot of listShots(dir)) if (shot.mtime < cutoff && remove(shot.file)) removed++;
93
+ try {
94
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
95
+ } catch { /* in use or gone */ }
96
+ }
97
+ }
98
+ return removed;
99
+ }
100
+
101
+ /** Write one screenshot and return its path. */
102
+ function save(base64Data, format) {
103
+ const dir = screenshotDir();
104
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
+ const filename = `screenshot-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${format === 'jpeg' ? 'jpg' : 'png'}`;
106
+ const file = path.join(dir, filename);
107
+ fs.writeFileSync(file, Buffer.from(base64Data, 'base64'), { mode: 0o600 });
108
+ // With 0 days the files live until the MCP server exits (pruneOnExit).
109
+ if (keepDays() > 0) prune({ legacy: false });
110
+ return file;
111
+ }
112
+
113
+ /** What the folder holds, for --screenshots and browser_status. */
114
+ function summary() {
115
+ const shots = listShots(screenshotDir());
116
+ let bytes = 0;
117
+ for (const s of shots) {
118
+ try {
119
+ bytes += fs.statSync(s.file).size;
120
+ } catch { /* gone */ }
121
+ }
122
+ return { folder: screenshotDir(), files: shots.length, bytes, keepDays: keepDays(), maxFiles: MAX_FILES };
123
+ }
124
+
125
+ /** With BROWSERZAP_SCREENSHOT_DAYS=0, delete every screenshot when the MCP server exits. */
126
+ function pruneOnExit() {
127
+ if (keepDays() === 0) prune({ days: 0, now: Date.now() + 1000, legacy: false });
128
+ }
129
+
130
+ module.exports = { save, prune, pruneOnExit, summary, screenshotDir, keepDays, MAX_FILES };
@@ -1,127 +1,127 @@
1
- /**
2
- * BrowserZap — per-install bridge secret.
3
- *
4
- * The bridge WebSocket is the only network listener in the system. Upstream
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
7
- * user's logged-in browser.
8
- *
9
- * BrowserZap closes that by requiring a shared secret on the first frame of every
10
- * connection. This module owns generation, storage and comparison of that
11
- * secret.
12
- *
13
- * Storage location:
14
- * Windows: %LOCALAPPDATA%\BrowserZap\bridge-secret
15
- * other: $XDG_CONFIG_HOME/BrowserZap/bridge-secret (default ~/.config/BrowserZap)
16
- * override: $BROWSERZAP_HOME
17
- *
18
- * The secret never leaves the machine and is never logged.
19
- */
20
-
21
- 'use strict';
22
-
23
- const crypto = require('crypto');
24
- const fs = require('fs');
25
- const os = require('os');
26
- const path = require('path');
27
-
28
- const SECRET_BYTES = 32;
29
- const SECRET_FILENAME = 'bridge-secret';
30
-
31
- /** Before 0.3.0 the product was called Browsio and kept its files under this folder name. */
32
- const LEGACY_DIR_NAME = 'Browsio';
33
- let legacyChecked = false;
34
-
35
- function configDir() {
36
- if (process.env.BROWSERZAP_HOME) return process.env.BROWSERZAP_HOME;
37
- const base = process.platform === 'win32'
38
- ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
39
- : process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
40
- const dir = path.join(base, 'BrowserZap');
41
- if (!legacyChecked) {
42
- legacyChecked = true;
43
- migrateLegacyDir(path.join(base, LEGACY_DIR_NAME), dir);
44
- }
45
- return dir;
46
- }
47
-
48
- /**
49
- * One-time move of the pre-rename folder, so an existing install keeps its
50
- * pairing (the extension already holds this secret), workflows and licence.
51
- * Never overwrites: if the new folder exists, the old one is left alone.
52
- */
53
- function migrateLegacyDir(legacy, dir) {
54
- try {
55
- if (fs.existsSync(dir) || !fs.existsSync(legacy)) return;
56
- fs.renameSync(legacy, dir);
57
- } catch {
58
- // Folder in use or not movable: start fresh; re-pairing is documented.
59
- }
60
- }
61
-
62
- function secretPath() {
63
- return path.join(configDir(), SECRET_FILENAME);
64
- }
65
-
66
- /**
67
- * Read the existing secret, or null if none has been created yet.
68
- * Never creates anything — reading is side-effect free.
69
- */
70
- function readSecret() {
71
- try {
72
- const value = fs.readFileSync(secretPath(), 'utf8').trim();
73
- return value.length > 0 ? value : null;
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- /**
80
- * Read the secret, creating it on first use.
81
- *
82
- * The file is written with mode 0600. On Windows the POSIX mode is largely
83
- * advisory — the file inherits the user's profile ACL, which is already
84
- * user-scoped under %LOCALAPPDATA%. Documented in docs/SECURITY.md.
85
- */
86
- function getOrCreateSecret() {
87
- const existing = readSecret();
88
- if (existing) return existing;
89
-
90
- const dir = configDir();
91
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
-
93
- const secret = crypto.randomBytes(SECRET_BYTES).toString('hex');
94
- const file = secretPath();
95
-
96
- // 'wx' fails if the file appeared between our read and write, so two racing
97
- // processes cannot end up with different secrets.
98
- try {
99
- fs.writeFileSync(file, secret + '\n', { mode: 0o600, flag: 'wx' });
100
- } catch (err) {
101
- if (err.code === 'EEXIST') {
102
- const raced = readSecret();
103
- if (raced) return raced;
104
- }
105
- throw err;
106
- }
107
-
108
- return secret;
109
- }
110
-
111
- /** Constant-time comparison. Length is compared first (crypto requires equal lengths). */
112
- function secretMatches(provided, expected) {
113
- if (typeof provided !== 'string' || typeof expected !== 'string') return false;
114
- const a = Buffer.from(provided, 'utf8');
115
- const b = Buffer.from(expected, 'utf8');
116
- if (a.length !== b.length) return false;
117
- return crypto.timingSafeEqual(a, b);
118
- }
119
-
120
- module.exports = {
121
- SECRET_BYTES,
122
- configDir,
123
- secretPath,
124
- readSecret,
125
- getOrCreateSecret,
126
- secretMatches,
127
- };
1
+ /**
2
+ * BrowserZap — per-install bridge secret.
3
+ *
4
+ * The bridge WebSocket is the only network listener in the system. Upstream
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
7
+ * user's logged-in browser.
8
+ *
9
+ * BrowserZap closes that by requiring a shared secret on the first frame of every
10
+ * connection. This module owns generation, storage and comparison of that
11
+ * secret.
12
+ *
13
+ * Storage location:
14
+ * Windows: %LOCALAPPDATA%\BrowserZap\bridge-secret
15
+ * other: $XDG_CONFIG_HOME/BrowserZap/bridge-secret (default ~/.config/BrowserZap)
16
+ * override: $BROWSERZAP_HOME
17
+ *
18
+ * The secret never leaves the machine and is never logged.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const crypto = require('crypto');
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+
28
+ const SECRET_BYTES = 32;
29
+ const SECRET_FILENAME = 'bridge-secret';
30
+
31
+ /** Before 0.3.0 the product was called Browsio and kept its files under this folder name. */
32
+ const LEGACY_DIR_NAME = 'Browsio';
33
+ let legacyChecked = false;
34
+
35
+ function configDir() {
36
+ if (process.env.BROWSERZAP_HOME) return process.env.BROWSERZAP_HOME;
37
+ const base = process.platform === 'win32'
38
+ ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
39
+ : process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
40
+ const dir = path.join(base, 'BrowserZap');
41
+ if (!legacyChecked) {
42
+ legacyChecked = true;
43
+ migrateLegacyDir(path.join(base, LEGACY_DIR_NAME), dir);
44
+ }
45
+ return dir;
46
+ }
47
+
48
+ /**
49
+ * One-time move of the pre-rename folder, so an existing install keeps its
50
+ * pairing (the extension already holds this secret), workflows and licence.
51
+ * Never overwrites: if the new folder exists, the old one is left alone.
52
+ */
53
+ function migrateLegacyDir(legacy, dir) {
54
+ try {
55
+ if (fs.existsSync(dir) || !fs.existsSync(legacy)) return;
56
+ fs.renameSync(legacy, dir);
57
+ } catch {
58
+ // Folder in use or not movable: start fresh; re-pairing is documented.
59
+ }
60
+ }
61
+
62
+ function secretPath() {
63
+ return path.join(configDir(), SECRET_FILENAME);
64
+ }
65
+
66
+ /**
67
+ * Read the existing secret, or null if none has been created yet.
68
+ * Never creates anything — reading is side-effect free.
69
+ */
70
+ function readSecret() {
71
+ try {
72
+ const value = fs.readFileSync(secretPath(), 'utf8').trim();
73
+ return value.length > 0 ? value : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Read the secret, creating it on first use.
81
+ *
82
+ * The file is written with mode 0600. On Windows the POSIX mode is largely
83
+ * advisory — the file inherits the user's profile ACL, which is already
84
+ * user-scoped under %LOCALAPPDATA%. Documented in docs/SECURITY.md.
85
+ */
86
+ function getOrCreateSecret() {
87
+ const existing = readSecret();
88
+ if (existing) return existing;
89
+
90
+ const dir = configDir();
91
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+
93
+ const secret = crypto.randomBytes(SECRET_BYTES).toString('hex');
94
+ const file = secretPath();
95
+
96
+ // 'wx' fails if the file appeared between our read and write, so two racing
97
+ // processes cannot end up with different secrets.
98
+ try {
99
+ fs.writeFileSync(file, secret + '\n', { mode: 0o600, flag: 'wx' });
100
+ } catch (err) {
101
+ if (err.code === 'EEXIST') {
102
+ const raced = readSecret();
103
+ if (raced) return raced;
104
+ }
105
+ throw err;
106
+ }
107
+
108
+ return secret;
109
+ }
110
+
111
+ /** Constant-time comparison. Length is compared first (crypto requires equal lengths). */
112
+ function secretMatches(provided, expected) {
113
+ if (typeof provided !== 'string' || typeof expected !== 'string') return false;
114
+ const a = Buffer.from(provided, 'utf8');
115
+ const b = Buffer.from(expected, 'utf8');
116
+ if (a.length !== b.length) return false;
117
+ return crypto.timingSafeEqual(a, b);
118
+ }
119
+
120
+ module.exports = {
121
+ SECRET_BYTES,
122
+ configDir,
123
+ secretPath,
124
+ readSecret,
125
+ getOrCreateSecret,
126
+ secretMatches,
127
+ };
@@ -36,6 +36,7 @@ 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 { LIMITS } = require('./limits');
39
40
 
40
41
  // ─── Constants ───────────────────────────────────────────────────────────────
41
42
 
@@ -72,7 +73,7 @@ const MAX_EXTENSION_MESSAGES_PER_SECOND = 500;
72
73
  const VALID_AGENT_ACTIONS = new Set([
73
74
  'status', 'inspect', 'navigate', 'click', 'fill', 'select_option', 'wait_for',
74
75
  'screenshot', 'press_key', 'scroll', 'pause', 'resume',
75
- 'new_tab', 'list_tabs', 'switch_tab', 'notify',
76
+ 'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify',
76
77
  ]);
77
78
 
78
79
  const VALID_MESSAGE_TYPES = new Set([
@@ -168,7 +169,8 @@ function createBridgeServer(options = {}) {
168
169
  * by browser_select_profile, or automatically on the first action while
169
170
  * exactly one profile is connected. It never changes by itself, so a session
170
171
  * cannot drift into another account.
171
- * @type {Map<string, { ws: WebSocket, name: string, profile: string | null, builtIn?: boolean }>}
172
+ * `ws` is null while a dropped session waits out its grace period (`graceTimer`).
173
+ * @type {Map<string, { ws: WebSocket | null, name: string, profile: string | null, builtIn?: boolean, graceTimer?: NodeJS.Timeout }>}
172
174
  */
173
175
  const agentSessions = new Map();
174
176
  /** @type {Map<WebSocket, string>} */
@@ -438,7 +440,7 @@ function createBridgeServer(options = {}) {
438
440
  const targetSession = msg.session;
439
441
  if (targetSession && agentSessions.has(targetSession)) {
440
442
  const agent = agentSessions.get(targetSession);
441
- if (agent.ws.readyState === WebSocket.OPEN) {
443
+ if (agent.ws && agent.ws.readyState === WebSocket.OPEN) {
442
444
  agent.ws.send(data.toString());
443
445
  }
444
446
  }
@@ -455,6 +457,12 @@ function createBridgeServer(options = {}) {
455
457
  return;
456
458
  }
457
459
 
460
+ // A clean exit: end the session now rather than after the grace period.
461
+ if (msg.type === 'session_end') {
462
+ endAgentSession(sessionId);
463
+ return;
464
+ }
465
+
458
466
  if (msg.type === 'session_update') {
459
467
  const session = agentSessions.get(sessionId);
460
468
  const name = sanitizeSessionName(msg.name);
@@ -520,11 +528,17 @@ function createBridgeServer(options = {}) {
520
528
  }
521
529
  const sessionId = socketToSession.get(ws);
522
530
  if (sessionId) {
523
- const session = agentSessions.get(sessionId);
524
- log(`Agent "${session?.name}" disconnected (session: ${sessionId})`);
525
- agentSessions.delete(sessionId);
526
531
  socketToSession.delete(ws);
527
- sendTo(profileSocket(session?.profile), { type: 'session_end', session: sessionId });
532
+ const session = agentSessions.get(sessionId);
533
+ if (!session) return;
534
+ // The connection dropped without a goodbye: the agent may come back with
535
+ // the same session id (a restart, a blip), so its tabs wait
536
+ // SESSION_GRACE_MS before the session ends (D-38).
537
+ log(`Agent "${session.name}" disconnected (session: ${sessionId}); ending it in ${LIMITS.SESSION_GRACE_MS / 1000} s unless it reconnects`);
538
+ session.ws = null;
539
+ session.graceTimer = setTimeout(() => {
540
+ if (agentSessions.get(sessionId) === session && !session.ws) endAgentSession(sessionId);
541
+ }, LIMITS.SESSION_GRACE_MS);
528
542
  }
529
543
  });
530
544
 
@@ -558,7 +572,29 @@ function createBridgeServer(options = {}) {
558
572
  return true;
559
573
  }
560
574
 
575
+ /** The agent said goodbye, or its grace period ran out: end the session, and with it its tabs. */
576
+ function endAgentSession(sessionId) {
577
+ const session = agentSessions.get(sessionId);
578
+ if (!session) return;
579
+ clearTimeout(session.graceTimer);
580
+ agentSessions.delete(sessionId);
581
+ if (session.ws) socketToSession.delete(session.ws);
582
+ log(`Agent "${session.name}" ended (session: ${sessionId})`);
583
+ sendTo(profileSocket(session.profile), { type: 'session_end', session: sessionId });
584
+ }
585
+
561
586
  function acceptAgent(ws, hello) {
587
+ // The same session reconnecting within its grace period keeps its profile and tabs.
588
+ const orphan = typeof hello.session === 'string' ? agentSessions.get(hello.session) : undefined;
589
+ if (orphan && !orphan.builtIn && orphan.ws === null) {
590
+ clearTimeout(orphan.graceTimer);
591
+ orphan.ws = ws;
592
+ socketToSession.set(ws, hello.session);
593
+ log(`Agent "${orphan.name}" reconnected (session: ${hello.session})`);
594
+ if (!orphan.profile && profiles.size === 1) bindSession(hello.session, [...profiles.keys()][0]);
595
+ safeSend(ws, { type: 'session_started', session: hello.session, name: orphan.name, bridgeVersion: BRIDGE_VERSION, profiles: profiles.size });
596
+ return;
597
+ }
562
598
  const requested = typeof hello.session === 'string' && SESSION_ID_RE.test(hello.session) && !agentSessions.has(hello.session)
563
599
  ? hello.session
564
600
  : null;
@@ -618,6 +654,7 @@ function createBridgeServer(options = {}) {
618
654
 
619
655
  async function close() {
620
656
  clearInterval(reaper);
657
+ for (const session of agentSessions.values()) clearTimeout(session.graceTimer);
621
658
  panel?.close();
622
659
  for (const client of wss.clients) {
623
660
  try { client.close(1001, 'server-shutdown'); } catch { /* best effort */ }