browserzap 0.4.0 → 0.5.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.
@@ -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,8 @@ 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', 'upload',
77
+ 'console', 'network',
76
78
  ]);
77
79
 
78
80
  const VALID_MESSAGE_TYPES = new Set([
@@ -168,7 +170,8 @@ function createBridgeServer(options = {}) {
168
170
  * by browser_select_profile, or automatically on the first action while
169
171
  * exactly one profile is connected. It never changes by itself, so a session
170
172
  * cannot drift into another account.
171
- * @type {Map<string, { ws: WebSocket, name: string, profile: string | null, builtIn?: boolean }>}
173
+ * `ws` is null while a dropped session waits out its grace period (`graceTimer`).
174
+ * @type {Map<string, { ws: WebSocket | null, name: string, profile: string | null, builtIn?: boolean, graceTimer?: NodeJS.Timeout }>}
172
175
  */
173
176
  const agentSessions = new Map();
174
177
  /** @type {Map<WebSocket, string>} */
@@ -438,7 +441,7 @@ function createBridgeServer(options = {}) {
438
441
  const targetSession = msg.session;
439
442
  if (targetSession && agentSessions.has(targetSession)) {
440
443
  const agent = agentSessions.get(targetSession);
441
- if (agent.ws.readyState === WebSocket.OPEN) {
444
+ if (agent.ws && agent.ws.readyState === WebSocket.OPEN) {
442
445
  agent.ws.send(data.toString());
443
446
  }
444
447
  }
@@ -455,6 +458,12 @@ function createBridgeServer(options = {}) {
455
458
  return;
456
459
  }
457
460
 
461
+ // A clean exit: end the session now rather than after the grace period.
462
+ if (msg.type === 'session_end') {
463
+ endAgentSession(sessionId);
464
+ return;
465
+ }
466
+
458
467
  if (msg.type === 'session_update') {
459
468
  const session = agentSessions.get(sessionId);
460
469
  const name = sanitizeSessionName(msg.name);
@@ -520,11 +529,17 @@ function createBridgeServer(options = {}) {
520
529
  }
521
530
  const sessionId = socketToSession.get(ws);
522
531
  if (sessionId) {
523
- const session = agentSessions.get(sessionId);
524
- log(`Agent "${session?.name}" disconnected (session: ${sessionId})`);
525
- agentSessions.delete(sessionId);
526
532
  socketToSession.delete(ws);
527
- sendTo(profileSocket(session?.profile), { type: 'session_end', session: sessionId });
533
+ const session = agentSessions.get(sessionId);
534
+ if (!session) return;
535
+ // The connection dropped without a goodbye: the agent may come back with
536
+ // the same session id (a restart, a blip), so its tabs wait
537
+ // 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`);
539
+ session.ws = null;
540
+ session.graceTimer = setTimeout(() => {
541
+ if (agentSessions.get(sessionId) === session && !session.ws) endAgentSession(sessionId);
542
+ }, LIMITS.SESSION_GRACE_MS);
528
543
  }
529
544
  });
530
545
 
@@ -558,7 +573,29 @@ function createBridgeServer(options = {}) {
558
573
  return true;
559
574
  }
560
575
 
576
+ /** The agent said goodbye, or its grace period ran out: end the session, and with it its tabs. */
577
+ function endAgentSession(sessionId) {
578
+ const session = agentSessions.get(sessionId);
579
+ if (!session) return;
580
+ clearTimeout(session.graceTimer);
581
+ agentSessions.delete(sessionId);
582
+ if (session.ws) socketToSession.delete(session.ws);
583
+ log(`Agent "${session.name}" ended (session: ${sessionId})`);
584
+ sendTo(profileSocket(session.profile), { type: 'session_end', session: sessionId });
585
+ }
586
+
561
587
  function acceptAgent(ws, hello) {
588
+ // The same session reconnecting within its grace period keeps its profile and tabs.
589
+ const orphan = typeof hello.session === 'string' ? agentSessions.get(hello.session) : undefined;
590
+ if (orphan && !orphan.builtIn && orphan.ws === null) {
591
+ clearTimeout(orphan.graceTimer);
592
+ orphan.ws = ws;
593
+ socketToSession.set(ws, hello.session);
594
+ log(`Agent "${orphan.name}" reconnected (session: ${hello.session})`);
595
+ if (!orphan.profile && profiles.size === 1) bindSession(hello.session, [...profiles.keys()][0]);
596
+ safeSend(ws, { type: 'session_started', session: hello.session, name: orphan.name, bridgeVersion: BRIDGE_VERSION, profiles: profiles.size });
597
+ return;
598
+ }
562
599
  const requested = typeof hello.session === 'string' && SESSION_ID_RE.test(hello.session) && !agentSessions.has(hello.session)
563
600
  ? hello.session
564
601
  : null;
@@ -618,6 +655,7 @@ function createBridgeServer(options = {}) {
618
655
 
619
656
  async function close() {
620
657
  clearInterval(reaper);
658
+ for (const session of agentSessions.values()) clearTimeout(session.graceTimer);
621
659
  panel?.close();
622
660
  for (const client of wss.clients) {
623
661
  try { client.close(1001, 'server-shutdown'); } catch { /* best effort */ }