@jobshimo/browser-link 0.18.2 → 0.19.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.
@@ -0,0 +1,158 @@
1
+ import { MAX_IDLE_TTL_MINUTES, MIN_IDLE_TTL_MINUTES, clampIdleTtlMinutes, loadConfig, saveConfig, } from '../config.js';
2
+ import { HandshakeError, IpcClient } from '../bridge/ipc-client.js';
3
+ import { readToken } from '../bridge/token.js';
4
+ const CFG_I18N = {
5
+ en: {
6
+ header: 'browser-link config',
7
+ idleTtlLabel: 'idle-ttl',
8
+ never: 'never (auto-disconnect disabled)',
9
+ minutes: (n) => `${n} min`,
10
+ notSetViaCli: "not set via CLI — the extension's own default/popup choice applies (30 min unless changed in the popup)",
11
+ invalidValue: (raw) => `Invalid idle-ttl value: "${raw}". Use a whole number of minutes (${MIN_IDLE_TTL_MINUTES}-${MAX_IDLE_TTL_MINUTES}) or "never".`,
12
+ clampedNote: (original, clamped) => ` (clamped from ${original} to ${clamped})`,
13
+ setSaved: (label) => `Idle-disconnect TTL set to ${label}.`,
14
+ pushedLive: (n) => ` Pushed immediately to ${n} connected tab(s).`,
15
+ pushedNone: ' No tabs are currently connected — it will apply the next time one connects.',
16
+ pushUnavailable: ' Could not reach a running browser-link primary (not running, or multi-agent mode is off) — it will apply the next time a tab connects.',
17
+ pushRejected: ' A running primary rejected the push (stale or mismatched multi-agent token — it may have just restarted). The value is saved and applies the next time a tab connects.',
18
+ usage: 'Usage: browser-link config get [idle-ttl] | browser-link config set idle-ttl <minutes|never>',
19
+ unknownKey: (key) => `Unknown config key: ${key}. Known keys: idle-ttl.`,
20
+ unknownAction: (action) => `Unknown config action: ${action}. Use get or set.`,
21
+ },
22
+ es: {
23
+ header: 'Configuración de browser-link',
24
+ idleTtlLabel: 'idle-ttl',
25
+ never: 'nunca (auto-desconexión deshabilitada)',
26
+ minutes: (n) => `${n} min`,
27
+ notSetViaCli: 'no configurado por CLI — se aplica el valor por defecto o el elegido en el popup de la extensión (30 min salvo que se haya cambiado en el popup)',
28
+ invalidValue: (raw) => `Valor de idle-ttl inválido: "${raw}". Usá un número entero de minutos (${MIN_IDLE_TTL_MINUTES}-${MAX_IDLE_TTL_MINUTES}) o "never".`,
29
+ clampedNote: (original, clamped) => ` (ajustado de ${original} a ${clamped})`,
30
+ setSaved: (label) => `TTL de auto-desconexión configurado a ${label}.`,
31
+ pushedLive: (n) => ` Aplicado de inmediato a ${n} pestaña(s) conectada(s).`,
32
+ pushedNone: ' No hay pestañas conectadas actualmente — se aplicará la próxima vez que una se conecte.',
33
+ pushUnavailable: ' No se pudo contactar a un browser-link primary en ejecución (no está corriendo, o el modo multi-agente está apagado) — se aplicará la próxima vez que una pestaña se conecte.',
34
+ pushRejected: ' Un primary en ejecución rechazó el push (token multi-agente obsoleto o no coincidente — puede haberse reiniciado recién). El valor quedó guardado y se aplicará la próxima vez que una pestaña se conecte.',
35
+ usage: 'Uso: browser-link config get [idle-ttl] | browser-link config set idle-ttl <minutos|never>',
36
+ unknownKey: (key) => `Clave de configuración desconocida: ${key}. Claves conocidas: idle-ttl.`,
37
+ unknownAction: (action) => `Acción de config desconocida: ${action}. Usá get o set.`,
38
+ },
39
+ };
40
+ /** One formatted line describing the current `idleTtlMinutes` state, shared
41
+ * by both `config get` (no key — lists everything) and `config get idle-ttl`
42
+ * (just this one). */
43
+ export function getIdleTtlLine(language = 'en') {
44
+ const t = CFG_I18N[language];
45
+ const cfg = loadConfig();
46
+ const value = cfg.idleTtlMinutes === undefined
47
+ ? t.notSetViaCli
48
+ : cfg.idleTtlMinutes === 0
49
+ ? t.never
50
+ : t.minutes(cfg.idleTtlMinutes);
51
+ return ` ${t.idleTtlLabel} ${value}`;
52
+ }
53
+ export function listConfig(language = 'en') {
54
+ const t = CFG_I18N[language];
55
+ return [t.header, '', getIdleTtlLine(language)].join('\n');
56
+ }
57
+ /**
58
+ * Parse the CLI's raw `<minutes|never>` argument.
59
+ *
60
+ * Distinct from `clampIdleTtlMinutes` (config.ts) on purpose: that function
61
+ * is a defensive safety net for VALUES THE USER NEVER TYPED (a corrupted
62
+ * config.json, a hand-edited file) — for those, silently falling back to
63
+ * the 30-minute default is the safer failure mode, since there's no one to
64
+ * tell. Here the user just typed something on a terminal RIGHT NOW: a
65
+ * genuinely unparsable value (letters, decimals) gets a clear error instead
66
+ * of a silent substitution, and an out-of-range integer gets clamped to the
67
+ * boundary WITH a note explaining what happened — both are more honest
68
+ * feedback loops for an interactive command than defaulting silently.
69
+ */
70
+ function parseIdleTtlArg(raw, t) {
71
+ const normalized = raw.trim().toLowerCase();
72
+ if (normalized === 'never' || normalized === '0')
73
+ return { minutes: 0, note: '' };
74
+ const parsed = Number(normalized);
75
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
76
+ throw new Error(t.invalidValue(raw));
77
+ }
78
+ if (parsed < MIN_IDLE_TTL_MINUTES || parsed > MAX_IDLE_TTL_MINUTES) {
79
+ const clamped = Math.min(Math.max(parsed, MIN_IDLE_TTL_MINUTES), MAX_IDLE_TTL_MINUTES);
80
+ return { minutes: clamped, note: t.clampedNote(parsed, clamped) };
81
+ }
82
+ return { minutes: parsed, note: '' };
83
+ }
84
+ /** Best-effort live push to an already-running primary over the
85
+ * multi-agent IPC bridge. Never throws — every failure degrades to a
86
+ * message. Failures are NOT collapsed into one diagnosis: an explicit
87
+ * hello-reject (stale/mismatched token, version mismatch) proves a primary
88
+ * IS running and gets `pushRejected`, while connection-refused / timeout /
89
+ * missing-token-file — where "no primary, or multi-agent off" is the
90
+ * honest reading — get `pushUnavailable`. */
91
+ async function tryPushLive(settings, t, ipcOptions = {}) {
92
+ const token = readToken();
93
+ if (!token)
94
+ return t.pushUnavailable;
95
+ const client = new IpcClient();
96
+ try {
97
+ await client.connect(token, {
98
+ host: ipcOptions.host,
99
+ port: ipcOptions.port,
100
+ handshakeTimeoutMs: 1500,
101
+ });
102
+ const ack = await client.sendSettingsPush(settings);
103
+ return ack.notified > 0 ? t.pushedLive(ack.notified) : t.pushedNone;
104
+ }
105
+ catch (err) {
106
+ // HandshakeError message shapes (see ipc-client.ts): "Primary
107
+ // rejected: <reason>" only when a live primary answered with an
108
+ // explicit hello-reject — timeouts and unexpected frames use other
109
+ // wording, and socket-level failures are not HandshakeErrors at all.
110
+ if (err instanceof HandshakeError && /rejected/i.test(err.message)) {
111
+ return t.pushRejected;
112
+ }
113
+ return t.pushUnavailable;
114
+ }
115
+ finally {
116
+ await client.disconnect().catch(() => {
117
+ /* best effort */
118
+ });
119
+ }
120
+ }
121
+ export async function setIdleTtl(rawValue, language = 'en', ipcOptions = {}) {
122
+ const t = CFG_I18N[language];
123
+ // Defensive final pass: parseIdleTtlArg already guarantees an in-range
124
+ // value or 0, so this is a no-op in practice — but it keeps ONE function
125
+ // as the single source of truth for "what counts as a safe stored value",
126
+ // matching the extension side's clampIdleTtlMinutes.
127
+ const { minutes: parsedMinutes, note } = parseIdleTtlArg(rawValue, t);
128
+ const minutes = clampIdleTtlMinutes(parsedMinutes);
129
+ const updatedAt = Date.now();
130
+ saveConfig({ idleTtlMinutes: minutes, idleTtlUpdatedAt: updatedAt });
131
+ const label = (minutes === 0 ? t.never : t.minutes(minutes)) + note;
132
+ const pushMessage = await tryPushLive({ idleTtlMinutes: minutes, updatedAt }, t, ipcOptions);
133
+ return t.setSaved(label) + pushMessage;
134
+ }
135
+ export async function runConfigCommand(argv, language = 'en') {
136
+ const t = CFG_I18N[language];
137
+ const action = argv.at(0);
138
+ const key = argv.at(1);
139
+ const value = argv.at(2);
140
+ if (action === undefined || action === 'get') {
141
+ if (key === undefined)
142
+ return listConfig(language);
143
+ if (key === 'idle-ttl')
144
+ return getIdleTtlLine(language);
145
+ throw new Error(t.unknownKey(key));
146
+ }
147
+ if (action === 'set') {
148
+ if (key === undefined)
149
+ throw new Error(t.usage);
150
+ if (key !== 'idle-ttl')
151
+ throw new Error(t.unknownKey(key));
152
+ if (value === undefined)
153
+ throw new Error(t.usage);
154
+ return setIdleTtl(value, language);
155
+ }
156
+ throw new Error(t.unknownAction(action));
157
+ }
158
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/commands/config.ts"],"names":[],"mappings":"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"}
package/dist/config.d.ts CHANGED
@@ -30,6 +30,44 @@ export interface BrowserLinkConfig {
30
30
  * the primary does). Has no effect when multiAgent is false.
31
31
  */
32
32
  autoReelect?: boolean;
33
+ /**
34
+ * CLI-side mirror of the extension's idle-disconnect TTL (see
35
+ * `packages/extension/src/idle-policy.ts`), in minutes. `0` means
36
+ * "never". Set via `browser-link config set idle-ttl <minutes|never>`.
37
+ *
38
+ * Deliberately left `undefined` — NOT defaulted to 30 here — when the
39
+ * CLI has never touched it: the WS bridge only pushes a `settings.update`
40
+ * to the extension when this field is explicitly set, so a popup-only
41
+ * user (who never ran the CLI command) never sees a spurious overwrite
42
+ * of their own choice with a server-side "default". See `idleTtlUpdatedAt`
43
+ * for the precedence rule when both sides have set a value.
44
+ */
45
+ idleTtlMinutes?: number;
46
+ /**
47
+ * Epoch-ms timestamp of the last `idleTtlMinutes` write from the CLI.
48
+ * Sent alongside `idleTtlMinutes` in every `settings.update` push so the
49
+ * extension can apply a last-write-wins precedence rule against its own
50
+ * locally-stored timestamp (popup edits stamp their own `Date.now()`).
51
+ * Always set together with `idleTtlMinutes` — never one without the other.
52
+ */
53
+ idleTtlUpdatedAt?: number;
33
54
  }
55
+ /** Safety-rail bounds mirrored from the extension's `idle-policy.ts` — kept
56
+ * as an independent copy (not a shared import) for the same reason
57
+ * `messages.ts` duplicates the wire types instead of depending on
58
+ * `@browser-link/shared`: the server publishes to npm standalone and must
59
+ * not carry an unresolvable workspace dependency. */
60
+ export declare const MIN_IDLE_TTL_MINUTES = 1;
61
+ export declare const MAX_IDLE_TTL_MINUTES = 1440;
62
+ export declare const DEFAULT_IDLE_TTL_MINUTES = 30;
63
+ /**
64
+ * Clamp a CLI-provided idle-TTL value the same way the extension clamps a
65
+ * stored one: `0` ("never") passes through untouched; a non-integer, an
66
+ * out-of-range value, or anything malformed falls back to
67
+ * `DEFAULT_IDLE_TTL_MINUTES` rather than being snapped to the nearest
68
+ * boundary. Exported so `commands/config.ts` and its tests share one
69
+ * implementation instead of re-deriving the bounds.
70
+ */
71
+ export declare function clampIdleTtlMinutes(value: number): number;
34
72
  export declare function loadConfig(): BrowserLinkConfig;
35
73
  export declare function saveConfig(patch: Partial<BrowserLinkConfig>): BrowserLinkConfig;
package/dist/config.js CHANGED
@@ -2,6 +2,31 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { getDataDir } from './map/paths.js';
4
4
  import { sanitizeDisabledTools } from './permissions.js';
5
+ /** Safety-rail bounds mirrored from the extension's `idle-policy.ts` — kept
6
+ * as an independent copy (not a shared import) for the same reason
7
+ * `messages.ts` duplicates the wire types instead of depending on
8
+ * `@browser-link/shared`: the server publishes to npm standalone and must
9
+ * not carry an unresolvable workspace dependency. */
10
+ export const MIN_IDLE_TTL_MINUTES = 1;
11
+ export const MAX_IDLE_TTL_MINUTES = 1440;
12
+ export const DEFAULT_IDLE_TTL_MINUTES = 30;
13
+ /**
14
+ * Clamp a CLI-provided idle-TTL value the same way the extension clamps a
15
+ * stored one: `0` ("never") passes through untouched; a non-integer, an
16
+ * out-of-range value, or anything malformed falls back to
17
+ * `DEFAULT_IDLE_TTL_MINUTES` rather than being snapped to the nearest
18
+ * boundary. Exported so `commands/config.ts` and its tests share one
19
+ * implementation instead of re-deriving the bounds.
20
+ */
21
+ export function clampIdleTtlMinutes(value) {
22
+ if (value === 0)
23
+ return 0;
24
+ if (!Number.isFinite(value) || !Number.isInteger(value))
25
+ return DEFAULT_IDLE_TTL_MINUTES;
26
+ if (value < MIN_IDLE_TTL_MINUTES || value > MAX_IDLE_TTL_MINUTES)
27
+ return DEFAULT_IDLE_TTL_MINUTES;
28
+ return value;
29
+ }
5
30
  /** Defaults applied at load time so consumers can read `cfg.multiAgent`
6
31
  * directly. Persisted form (see `normaliseForWrite`) only carries the field
7
32
  * when the user overrides the default, keeping the on-disk config minimal. */
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -2,23 +2,81 @@ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs,
2
2
  import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
3
3
  import { resolveSettleParams, settleSafely } from './settle.js';
4
4
  import { runFlow, } from './flow.js';
5
+ import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
5
6
  const WS_URL = 'ws://127.0.0.1:17529';
6
7
  const CDP_VERSION = '1.3';
7
8
  const CONSOLE_BUFFER_MAX = 200;
8
9
  const NETWORK_BUFFER_MAX = 200;
9
10
  /* Idle TTL for a tab's WebSocket bridge.
10
11
  *
11
- * After this many milliseconds without a tool.request from the server,
12
- * the extension closes its side of the WS and detaches the debugger.
13
- * The popup then shows "Not connected" again and the user explicitly
14
- * re-presses Connect when they want to reactivate.
12
+ * After this many minutes without a tool.request from the server, the
13
+ * extension closes its side of the WS and detaches the debugger. The
14
+ * popup then shows "Not connected" again and the user explicitly
15
+ * re-presses Connect when they want to reactivate — unless the user
16
+ * configured "Never" in the popup, in which case the sweep never fires.
15
17
  *
16
18
  * This replaces the previous behaviour where the bridge implicitly
17
19
  * died together with the MCP server subprocess (parent_death_guard,
18
20
  * removed in v0.9.0). Lifecycle is now client-side: agent activity
19
- * keeps the tab warm, silence eventually parks it. */
20
- const WS_IDLE_TTL_MS = 30 * 60 * 1000;
21
+ * keeps the tab warm, silence eventually parks it (or never does, if
22
+ * the user opted out).
23
+ *
24
+ * The TTL itself is user-configurable (`idleTtlMinutes` in
25
+ * `chrome.storage.local`, edited from the popup — see idle-policy.ts for
26
+ * the decision logic and popup.ts for the control). `idleTtlMinutesCache`
27
+ * is the in-memory mirror the sweep reads on every tick: chrome.storage is
28
+ * async, the sweep loop below is not, so the value is loaded once at
29
+ * startup and kept current via `chrome.storage.onChanged` instead of being
30
+ * awaited inside the interval callback. */
21
31
  const WS_IDLE_SWEEP_MS = 60 * 1000;
32
+ let idleTtlMinutesCache = DEFAULT_IDLE_TTL_MINUTES;
33
+ async function loadIdleTtlMinutes() {
34
+ try {
35
+ const data = await chrome.storage.local.get(IDLE_TTL_STORAGE_KEY);
36
+ idleTtlMinutesCache = clampIdleTtlMinutes(data[IDLE_TTL_STORAGE_KEY]);
37
+ }
38
+ catch {
39
+ idleTtlMinutesCache = DEFAULT_IDLE_TTL_MINUTES;
40
+ }
41
+ }
42
+ void loadIdleTtlMinutes();
43
+ chrome.storage.onChanged.addListener((changes, areaName) => {
44
+ if (areaName !== 'local')
45
+ return;
46
+ if (!(IDLE_TTL_STORAGE_KEY in changes))
47
+ return;
48
+ idleTtlMinutesCache = clampIdleTtlMinutes(changes[IDLE_TTL_STORAGE_KEY].newValue);
49
+ });
50
+ /**
51
+ * Apply an incoming `settings.update` push from the server (see
52
+ * `handleTool`'s caller — the WS message listener in `connectTab` below).
53
+ * The server sends this both right after `tab.registered` (when
54
+ * `browser-link config set idle-ttl` was run at least once) AND on demand
55
+ * when the CLI pushes a change to an already-connected tab.
56
+ *
57
+ * Applies `shouldAcceptIncomingSettings` (idle-policy.ts) — newest
58
+ * `updatedAt` wins against whatever was last written locally (typically by
59
+ * the popup). Writing through `chrome.storage.local.set` here is enough to
60
+ * take effect: the `onChanged` listener above keeps `idleTtlMinutesCache`
61
+ * current, and the popup re-reads storage on its own refresh interval.
62
+ */
63
+ async function applyIncomingSettings(settings) {
64
+ try {
65
+ const data = await chrome.storage.local.get(IDLE_TTL_UPDATED_AT_STORAGE_KEY);
66
+ const rawLocalUpdatedAt = data[IDLE_TTL_UPDATED_AT_STORAGE_KEY];
67
+ const localUpdatedAt = typeof rawLocalUpdatedAt === 'number' ? rawLocalUpdatedAt : undefined;
68
+ if (!shouldAcceptIncomingSettings(localUpdatedAt, settings.updatedAt))
69
+ return;
70
+ await chrome.storage.local.set({
71
+ [IDLE_TTL_STORAGE_KEY]: clampIdleTtlMinutes(settings.idleTtlMinutes),
72
+ [IDLE_TTL_UPDATED_AT_STORAGE_KEY]: settings.updatedAt,
73
+ });
74
+ }
75
+ catch {
76
+ // Best effort — a failed sync just leaves the local value as-is; the
77
+ // next settings.update (or a popup edit) can still apply it.
78
+ }
79
+ }
22
80
  function isRuntimeMessage(msg) {
23
81
  if (typeof msg !== 'object' || msg === null)
24
82
  return false;
@@ -1436,27 +1494,56 @@ async function connectTab(tabId) {
1436
1494
  const msg = safeParse(typeof ev.data === 'string' ? ev.data : '');
1437
1495
  if (!msg)
1438
1496
  return;
1439
- if (msg.kind === 'tab.registered') {
1440
- state.serverTabId = msg.payload.tabId;
1441
- // serverVersion is optional on the wire so older servers that
1442
- // predate the field still parse — narrow on `typeof string`.
1443
- if (typeof msg.payload.serverVersion === 'string') {
1444
- state.serverVersion = msg.payload.serverVersion;
1497
+ // Exhaustive routing over ServerToExtension. The `default` branch
1498
+ // pins `msg` to `never`, so adding a fourth message kind to the
1499
+ // union fails to COMPILE here instead of silently misrouting the
1500
+ // new kind into the tool.request handler.
1501
+ switch (msg.kind) {
1502
+ case 'tab.registered': {
1503
+ state.serverTabId = msg.payload.tabId;
1504
+ // serverVersion is optional on the wire so older servers that
1505
+ // predate the field still parse — narrow on `typeof string`.
1506
+ if (typeof msg.payload.serverVersion === 'string') {
1507
+ state.serverVersion = msg.payload.serverVersion;
1508
+ }
1509
+ // Remember this id so the next reconnect (after a primary swap)
1510
+ // asks the new primary to honour it. The primary emits
1511
+ // `tab-renamed` in the bridge event log if it can't.
1512
+ void storeTabId(tabId, msg.payload.tabId);
1513
+ settle({ ok: true, serverTabId: msg.payload.tabId });
1514
+ return;
1515
+ }
1516
+ case 'settings.update': {
1517
+ // Housekeeping, not agent activity — deliberately does NOT touch
1518
+ // state.lastActivityAt. Counting a server-pushed settings sync as
1519
+ // "activity" would let it silently keep an otherwise-idle tab
1520
+ // connected forever, defeating the idle TTL it is updating.
1521
+ //
1522
+ // `safeParse` is only a cast — validate the payload shape at the
1523
+ // wire boundary (same rigor the IPC settings.push frame gets in
1524
+ // the server's protocol.ts) so a malformed push is dropped here
1525
+ // instead of throwing inside the apply path.
1526
+ const settings = parseIncomingSettings(msg.settings);
1527
+ if (settings)
1528
+ void applyIncomingSettings(settings);
1529
+ return;
1530
+ }
1531
+ case 'tool.request': {
1532
+ // Touch the activity timestamp so the WS-idle sweeper keeps
1533
+ // this tab warm.
1534
+ state.lastActivityAt = Date.now();
1535
+ const response = await handleTool(state, msg);
1536
+ if (ws.readyState === WebSocket.OPEN)
1537
+ send(ws, response);
1538
+ return;
1539
+ }
1540
+ default: {
1541
+ // Compile-time exhaustiveness check; at runtime an unknown kind
1542
+ // (newer server, older extension) is ignored defensively.
1543
+ const _exhaustive = msg;
1544
+ return;
1445
1545
  }
1446
- // Remember this id so the next reconnect (after a primary swap)
1447
- // asks the new primary to honour it. The primary emits
1448
- // `tab-renamed` in the bridge event log if it can't.
1449
- void storeTabId(tabId, msg.payload.tabId);
1450
- settle({ ok: true, serverTabId: msg.payload.tabId });
1451
- return;
1452
1546
  }
1453
- // ServerToExtension is the union { tab.registered | tool.request },
1454
- // so by elimination this branch handles the tool.request case. Touch
1455
- // the activity timestamp so the WS-idle sweeper keeps this tab warm.
1456
- state.lastActivityAt = Date.now();
1457
- const response = await handleTool(state, msg);
1458
- if (ws.readyState === WebSocket.OPEN)
1459
- send(ws, response);
1460
1547
  })();
1461
1548
  });
1462
1549
  ws.addEventListener('error', () => {
@@ -1651,17 +1738,27 @@ chrome.debugger.onDetach.addListener((source) => {
1651
1738
  /* WS-idle sweeper.
1652
1739
  *
1653
1740
  * Every WS_IDLE_SWEEP_MS, walk every connected tab. Any tab whose last
1654
- * tool.request landed more than WS_IDLE_TTL_MS ago gets disconnected:
1655
- * the WS closes, the debugger detaches, the popup goes back to "Not
1656
- * connected". The user explicitly re-presses Connect to bring it back.
1741
+ * tool.request landed more than the user's configured `idleTtlMinutes`
1742
+ * ago gets disconnected: the WS closes, the debugger detaches, the popup
1743
+ * goes back to "Not connected". The user explicitly re-presses Connect to
1744
+ * bring it back.
1745
+ *
1746
+ * When the user picked "Never" (idleTtlMinutesCache === 0),
1747
+ * `shouldScheduleIdleSweep` short-circuits the tick before it walks
1748
+ * `tabStates` — the interval itself keeps ticking (cancelling/rearming a
1749
+ * service-worker timer from a storage listener is more moving parts than
1750
+ * it is worth for a once-a-minute no-op), but each tick does zero work
1751
+ * beyond that one check.
1657
1752
  *
1658
1753
  * This is the replacement for the parent_death_guard that used to kill
1659
1754
  * the entire MCP server on stdio close — now the server stays alive and
1660
1755
  * the bridge is parked tab-by-tab from the client side. */
1661
1756
  setInterval(() => {
1757
+ if (!shouldScheduleIdleSweep(idleTtlMinutesCache))
1758
+ return;
1662
1759
  const now = Date.now();
1663
1760
  for (const [tabId, state] of tabStates) {
1664
- if (now - state.lastActivityAt < WS_IDLE_TTL_MS)
1761
+ if (!shouldDisconnectForIdle(state.lastActivityAt, now, idleTtlMinutesCache))
1665
1762
  continue;
1666
1763
  void cleanup(tabId).catch(() => {
1667
1764
  // Best effort.