@monoes/monobrowse 1.0.26 → 1.0.28

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.
Files changed (101) hide show
  1. package/dist/src/__tests__/disconnect-session.test.d.ts +13 -0
  2. package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/disconnect-session.test.js +37 -0
  4. package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
  5. package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
  6. package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
  8. package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
  9. package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
  10. package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/profile-dir.test.js +160 -0
  12. package/dist/src/__tests__/profile-dir.test.js.map +1 -0
  13. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
  14. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
  15. package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
  16. package/dist/src/__tests__/setup/isolated-tmpdir.global.js.map +1 -0
  17. package/dist/src/browser/actions-eval.d.ts +8 -0
  18. package/dist/src/browser/actions-eval.d.ts.map +1 -0
  19. package/dist/src/browser/actions-eval.js +72 -0
  20. package/dist/src/browser/actions-eval.js.map +1 -0
  21. package/dist/src/browser/actions-form.d.ts +10 -0
  22. package/dist/src/browser/actions-form.d.ts.map +1 -0
  23. package/dist/src/browser/actions-form.js +133 -0
  24. package/dist/src/browser/actions-form.js.map +1 -0
  25. package/dist/src/browser/actions-keyboard.d.ts +7 -0
  26. package/dist/src/browser/actions-keyboard.d.ts.map +1 -0
  27. package/dist/src/browser/actions-keyboard.js +131 -0
  28. package/dist/src/browser/actions-keyboard.js.map +1 -0
  29. package/dist/src/browser/actions-mouse.d.ts +11 -0
  30. package/dist/src/browser/actions-mouse.d.ts.map +1 -0
  31. package/dist/src/browser/actions-mouse.js +105 -0
  32. package/dist/src/browser/actions-mouse.js.map +1 -0
  33. package/dist/src/browser/actions.d.ts +12 -28
  34. package/dist/src/browser/actions.d.ts.map +1 -1
  35. package/dist/src/browser/actions.js +12 -434
  36. package/dist/src/browser/actions.js.map +1 -1
  37. package/dist/src/browser/browser-discovery.d.ts +26 -0
  38. package/dist/src/browser/browser-discovery.d.ts.map +1 -0
  39. package/dist/src/browser/browser-discovery.js +113 -0
  40. package/dist/src/browser/browser-discovery.js.map +1 -0
  41. package/dist/src/browser/browser-launch.d.ts +3 -0
  42. package/dist/src/browser/browser-launch.d.ts.map +1 -0
  43. package/dist/src/browser/browser-launch.js +341 -0
  44. package/dist/src/browser/browser-launch.js.map +1 -0
  45. package/dist/src/browser/browser-lifecycle.d.ts +57 -0
  46. package/dist/src/browser/browser-lifecycle.d.ts.map +1 -0
  47. package/dist/src/browser/browser-lifecycle.js +245 -0
  48. package/dist/src/browser/browser-lifecycle.js.map +1 -0
  49. package/dist/src/browser/browser-page.d.ts +13 -0
  50. package/dist/src/browser/browser-page.d.ts.map +1 -0
  51. package/dist/src/browser/browser-page.js +179 -0
  52. package/dist/src/browser/browser-page.js.map +1 -0
  53. package/dist/src/browser/browser-state.d.ts +11 -0
  54. package/dist/src/browser/browser-state.d.ts.map +1 -0
  55. package/dist/src/browser/browser-state.js +27 -0
  56. package/dist/src/browser/browser-state.js.map +1 -0
  57. package/dist/src/browser/browser.d.ts +5 -68
  58. package/dist/src/browser/browser.d.ts.map +1 -1
  59. package/dist/src/browser/browser.js +9 -849
  60. package/dist/src/browser/browser.js.map +1 -1
  61. package/dist/src/browser/profile-dir.d.ts +88 -0
  62. package/dist/src/browser/profile-dir.d.ts.map +1 -0
  63. package/dist/src/browser/profile-dir.js +242 -0
  64. package/dist/src/browser/profile-dir.js.map +1 -0
  65. package/dist/src/browser/ref-cache.d.ts +4 -0
  66. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  67. package/dist/src/browser/ref-cache.js +2 -0
  68. package/dist/src/browser/ref-cache.js.map +1 -1
  69. package/dist/src/browser/types.d.ts +6 -0
  70. package/dist/src/browser/types.d.ts.map +1 -1
  71. package/dist/src/browser/types.js.map +1 -1
  72. package/dist/src/cli/commands.d.ts +1 -0
  73. package/dist/src/cli/commands.d.ts.map +1 -1
  74. package/dist/src/cli/commands.js +2 -0
  75. package/dist/src/cli/commands.js.map +1 -1
  76. package/dist/src/cli/session.d.ts +9 -0
  77. package/dist/src/cli/session.d.ts.map +1 -1
  78. package/dist/src/cli/session.js +21 -4
  79. package/dist/src/cli/session.js.map +1 -1
  80. package/dist/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +1 -1
  82. package/src/__tests__/disconnect-session.test.ts +42 -0
  83. package/src/__tests__/profile-cleanup-close.test.ts +285 -0
  84. package/src/__tests__/profile-dir.test.ts +196 -0
  85. package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
  86. package/src/browser/actions-eval.ts +118 -0
  87. package/src/browser/actions-form.ts +218 -0
  88. package/src/browser/actions-keyboard.ts +171 -0
  89. package/src/browser/actions-mouse.ts +212 -0
  90. package/src/browser/actions.ts +37 -712
  91. package/src/browser/browser-discovery.ts +116 -0
  92. package/src/browser/browser-launch.ts +375 -0
  93. package/src/browser/browser-lifecycle.ts +248 -0
  94. package/src/browser/browser-page.ts +213 -0
  95. package/src/browser/browser-state.ts +30 -0
  96. package/src/browser/browser.ts +21 -917
  97. package/src/browser/profile-dir.ts +249 -0
  98. package/src/browser/ref-cache.ts +7 -0
  99. package/src/browser/types.ts +6 -0
  100. package/src/cli/commands.ts +2 -0
  101. package/src/cli/session.ts +22 -4
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Temporary Chrome profile directories monobrowse creates, and their removal
3
+ * (#395).
4
+ *
5
+ * Every launch without a caller-supplied `userDataDir` (and every CLI
6
+ * session) gets a fresh `--user-data-dir` directly under `os.tmpdir()`.
7
+ * Nothing used to delete them, so they piled up until /tmp ran out of space
8
+ * (81 profiles, 4.4 GB on one machine). Two paths remove them now:
9
+ *
10
+ * - removeOwnedProfileDir(): when monobrowse closes or kills a browser it
11
+ * launched in a directory it created — after Chrome has exited, since
12
+ * Chrome writes into its profile until then.
13
+ * - sweepStaleProfileDirs(): at the next launch, for directories whose
14
+ * owner crashed or was killed before it could close its browser.
15
+ *
16
+ * Both touch only real directories directly inside the tmp root whose name
17
+ * matches one of the exact patterns below. A caller-supplied `userDataDir`
18
+ * never matches them unless the caller copied our naming, and even then the
19
+ * close path also needs the explicit ownership flag launchBrowser records.
20
+ */
21
+
22
+ import { lstat, readdir, readFile, readlink, realpath, rm } from 'node:fs/promises';
23
+ import { tmpdir } from 'node:os';
24
+ import { basename, dirname, join } from 'node:path';
25
+
26
+ /** `monomind-browser-<port>-<pid>-<uuid8>` (launchBrowser's default profile). */
27
+ const LAUNCH_PROFILE_RE = /^monomind-browser-\d+-(\d+)-[0-9a-f]{8}$/;
28
+ /** `monomind-browse-<pid>-<uuid8>` (a CLI browse session's profile). */
29
+ const SESSION_PROFILE_RE = /^monomind-browse-(\d+)-[0-9a-f]{8}$/;
30
+
31
+ /** A directory younger than this is never swept: its launch may still be
32
+ * starting (Chrome has not yet put its pid anywhere we can see). */
33
+ export const PROFILE_SWEEP_GRACE_MS = 10 * 60 * 1000;
34
+ // Bounded waits for removeOwnedProfileDir(): for the browser pid to go, then
35
+ // for any other process still holding the profile (Chrome's helpers).
36
+ const EXIT_WAIT_MS = 5000;
37
+ const HOLDER_WAIT_MS = 2000;
38
+ const POLL_MS = 50;
39
+ const RM_RETRY_DELAY_MS = 250;
40
+
41
+ /** Name for a new default launch profile, directly under `root`. */
42
+ export function launchProfileDirPath(port: number, id: string, root = tmpdir()): string {
43
+ return join(root, `monomind-browser-${port}-${process.pid}-${id}`);
44
+ }
45
+
46
+ /** Name for a new CLI session profile, directly under `root`. */
47
+ export function sessionProfileDirPath(id: string, root = tmpdir()): string {
48
+ return join(root, `monomind-browse-${process.pid}-${id}`);
49
+ }
50
+
51
+ /** The pid encoded in a profile directory name we create, or null when
52
+ * `name` is not one of ours. */
53
+ export function profileDirOwnerPid(name: string): number | null {
54
+ const m = LAUNCH_PROFILE_RE.exec(name) ?? SESSION_PROFILE_RE.exec(name);
55
+ return m ? Number(m[1]) : null;
56
+ }
57
+
58
+ /**
59
+ * Whether `dir` is a directory monobrowse created: its name matches our
60
+ * pattern, it is a real directory (not a symlink), and its resolved parent
61
+ * is `root`. Anything else is never deleted.
62
+ */
63
+ export async function isOwnedProfileDir(dir: string, root = tmpdir()): Promise<boolean> {
64
+ try {
65
+ if (profileDirOwnerPid(basename(dir)) === null) return false;
66
+ const st = await lstat(dir);
67
+ if (!st.isDirectory() || st.isSymbolicLink()) return false;
68
+ return dirname(await realpath(dir)) === (await realpath(root));
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Whether `pid` is a live process. EPERM means it exists but is not ours. */
75
+ export function isPidAlive(pid: number): boolean {
76
+ if (!Number.isInteger(pid) || pid <= 0) return false;
77
+ try {
78
+ process.kill(pid, 0);
79
+ return true;
80
+ } catch (err) {
81
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Pids of live processes whose command line passes `--user-data-dir=<dir>`,
87
+ * read from `procDir` (Linux /proc). Null when it cannot be read at all, so
88
+ * the caller falls back to other evidence instead of reading "nobody".
89
+ */
90
+ export async function profileDirHolders(dir: string, procDir = '/proc'): Promise<number[] | null> {
91
+ let entries: string[];
92
+ try {
93
+ entries = await readdir(procDir);
94
+ } catch {
95
+ return null;
96
+ }
97
+ const wanted = new Set([`--user-data-dir=${dir}`]);
98
+ try {
99
+ wanted.add(`--user-data-dir=${await realpath(dir)}`);
100
+ } catch {
101
+ /* gone already — the literal path is all there is to match */
102
+ }
103
+ const holders: number[] = [];
104
+ for (const entry of entries) {
105
+ if (!/^\d+$/.test(entry)) continue;
106
+ try {
107
+ const args = (await readFile(join(procDir, entry, 'cmdline'), 'utf8')).split('\0');
108
+ if (args.some((a) => wanted.has(a))) holders.push(Number(entry));
109
+ } catch {
110
+ /* exited while we looked, or not readable */
111
+ }
112
+ }
113
+ return holders;
114
+ }
115
+
116
+ /** The live pid Chrome's `SingletonLock` (a `<host>-<pid>` symlink) names,
117
+ * or null when there is no lock or its pid is gone. */
118
+ async function singletonLockHolder(dir: string): Promise<number | null> {
119
+ try {
120
+ const m = /-(\d+)$/.exec(await readlink(join(dir, 'SingletonLock')));
121
+ const pid = m ? Number(m[1]) : Number.NaN;
122
+ return isPidAlive(pid) ? pid : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Whether any live process still uses `dir` as its Chrome profile. `procDir`
129
+ * null skips the /proc scan (non-Linux), leaving only the SingletonLock. */
130
+ export async function isProfileDirInUse(
131
+ dir: string,
132
+ opts: { procDir?: string | null } = {},
133
+ ): Promise<boolean> {
134
+ const procDir = opts.procDir === undefined ? defaultProcDir() : opts.procDir;
135
+ if (procDir !== null) {
136
+ const holders = await profileDirHolders(dir, procDir);
137
+ if (holders?.some(isPidAlive)) return true;
138
+ }
139
+ return (await singletonLockHolder(dir)) !== null;
140
+ }
141
+
142
+ function defaultProcDir(): string | null {
143
+ return process.platform === 'linux' ? '/proc' : null;
144
+ }
145
+
146
+ /**
147
+ * Delete a profile directory monobrowse created, once the browser `pid` that
148
+ * used it has exited (bounded wait) and no other process still holds it.
149
+ * Removal is retried once — Chrome's helpers can still be flushing a file
150
+ * as the first attempt runs. Returns whether the directory is gone. Never
151
+ * throws: a directory left behind is swept at a later launch.
152
+ */
153
+ export async function removeOwnedProfileDir(
154
+ dir: string,
155
+ opts: { pid?: number; root?: string; procDir?: string | null } = {},
156
+ ): Promise<boolean> {
157
+ try {
158
+ if (!(await isOwnedProfileDir(dir, opts.root))) return false;
159
+ if (opts.pid !== undefined && !(await waitUntil(() => !isPidAlive(opts.pid!), EXIT_WAIT_MS))) {
160
+ return false;
161
+ }
162
+ if (!(await waitUntil(async () => !(await isProfileDirInUse(dir, opts)), HOLDER_WAIT_MS))) {
163
+ return false;
164
+ }
165
+ for (let attempt = 0; attempt < 2; attempt++) {
166
+ try {
167
+ await rm(dir, { recursive: true, force: true });
168
+ return true;
169
+ } catch {
170
+ await delay(RM_RETRY_DELAY_MS);
171
+ }
172
+ }
173
+ } catch {
174
+ /* best-effort */
175
+ }
176
+ return false;
177
+ }
178
+
179
+ export interface ProfileSweepOptions {
180
+ /** Directory to sweep. Default os.tmpdir(); tests inject their own. */
181
+ root?: string;
182
+ /** Minimum age (by mtime) before a directory may be swept. */
183
+ graceMs?: number;
184
+ /** See isProfileDirInUse. */
185
+ procDir?: string | null;
186
+ now?: number;
187
+ }
188
+
189
+ /**
190
+ * Delete profile directories left behind by a monobrowse process that died
191
+ * without closing its browser. A directory directly in `root` is removed
192
+ * only when ALL of these hold:
193
+ * - its name matches one of our exact patterns and it is a real directory
194
+ * (symlinks, files and anything else are never touched);
195
+ * - its mtime is older than the grace period, so a concurrent launch that
196
+ * is still starting is left alone;
197
+ * - the pid in its name is not a live process;
198
+ * - no live Chrome uses it (/proc command lines on Linux, plus the
199
+ * profile's SingletonLock everywhere). A CLI `open` process exits right
200
+ * after launching, so this check is what keeps a live session's profile.
201
+ *
202
+ * Best-effort: any error skips that entry, and the sweep never throws.
203
+ * Returns the directories removed.
204
+ */
205
+ export async function sweepStaleProfileDirs(opts: ProfileSweepOptions = {}): Promise<string[]> {
206
+ const root = opts.root ?? tmpdir();
207
+ const cutoff = (opts.now ?? Date.now()) - (opts.graceMs ?? PROFILE_SWEEP_GRACE_MS);
208
+ const removed: string[] = [];
209
+ let entries: string[];
210
+ try {
211
+ entries = await readdir(root);
212
+ } catch {
213
+ return removed;
214
+ }
215
+ for (const name of entries) {
216
+ try {
217
+ const pid = profileDirOwnerPid(name);
218
+ if (pid === null) continue;
219
+ const dir = join(root, name);
220
+ const st = await lstat(dir);
221
+ if (!st.isDirectory() || st.mtimeMs >= cutoff) continue;
222
+ if (isPidAlive(pid)) continue;
223
+ if (!(await isOwnedProfileDir(dir, root))) continue;
224
+ if (await isProfileDirInUse(dir, opts)) continue;
225
+ await rm(dir, { recursive: true, force: true });
226
+ removed.push(dir);
227
+ } catch {
228
+ /* skip this entry */
229
+ }
230
+ }
231
+ return removed;
232
+ }
233
+
234
+ /** Poll `cond` until it holds or `ms` elapses. The timer is ref'd on purpose
235
+ * (see waitForProcessExit in browser-lifecycle.ts): callers await it. */
236
+ async function waitUntil(cond: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
237
+ const deadline = Date.now() + ms;
238
+ for (;;) {
239
+ if (await cond()) return true;
240
+ if (Date.now() >= deadline) return false;
241
+ await delay(POLL_MS);
242
+ }
243
+ }
244
+
245
+ function delay(ms: number): Promise<void> {
246
+ return new Promise((resolve) => {
247
+ setTimeout(resolve, ms);
248
+ });
249
+ }
@@ -118,6 +118,9 @@ export interface SessionRecord {
118
118
  launched: boolean;
119
119
  pid?: number;
120
120
  userDataDir?: string;
121
+ /** true = monobrowse created `userDataDir` itself, so closing this session
122
+ * deletes it (#395). Absent for a caller-supplied or older record. */
123
+ ownsUserDataDir?: boolean;
121
124
  savedAt?: number;
122
125
  /** Set by `--session <name>`: only commands naming it resolve this session,
123
126
  * and commands naming none never land on it. */
@@ -142,6 +145,7 @@ export async function saveSessionRecord(
142
145
  launched?: boolean;
143
146
  pid?: number;
144
147
  userDataDir?: string;
148
+ ownsUserDataDir?: boolean;
145
149
  savedAt?: number;
146
150
  name?: string;
147
151
  },
@@ -161,6 +165,7 @@ export async function saveSessionRecord(
161
165
  launched: opts?.launched !== false,
162
166
  pid: opts?.pid,
163
167
  userDataDir: opts?.userDataDir,
168
+ ownsUserDataDir: opts?.ownsUserDataDir === true || undefined,
164
169
  savedAt: opts?.savedAt ?? Date.now(),
165
170
  name: opts?.name,
166
171
  }),
@@ -247,6 +252,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
247
252
  launched?: unknown;
248
253
  pid?: unknown;
249
254
  userDataDir?: unknown;
255
+ ownsUserDataDir?: unknown;
250
256
  savedAt?: unknown;
251
257
  name?: unknown;
252
258
  };
@@ -266,6 +272,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
266
272
  ? data.pid
267
273
  : undefined,
268
274
  userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
275
+ ownsUserDataDir: data.ownsUserDataDir === true || undefined,
269
276
  savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
270
277
  name: typeof data.name === 'string' && SESSION_NAME_RE.test(data.name) ? data.name : undefined,
271
278
  };
@@ -10,6 +10,12 @@ export interface BrowserConfig {
10
10
  headless?: boolean;
11
11
  executablePath?: string;
12
12
  userDataDir?: string;
13
+ /** The caller created `userDataDir` for this launch alone (the CLI's
14
+ * session profile), so monobrowse deletes it when the browser closes,
15
+ * like the default profile. Only honoured for a directory that matches
16
+ * monobrowse's own tmpdir naming; any other `userDataDir` is never
17
+ * deleted. */
18
+ ownsUserDataDir?: boolean;
13
19
  args?: string[];
14
20
  /** Milliseconds to wait for the launched Chrome to open its CDP port
15
21
  * before giving up. Default 10000 — raise this for environments where
@@ -97,6 +97,8 @@ import type { Command, CommandContext, CommandResult } from './types.js';
97
97
  // before the subcommands were split out, and the tests import them from here.
98
98
  export { deriveBoxOutput } from './commands-page.js';
99
99
  export { parseBatchCommandLine } from './commands-script.js';
100
+ // For embedders that do not process.exit() after a subcommand (#408).
101
+ export { disconnectSession } from './session.js';
100
102
 
101
103
  // ---------------------------------------------------------------------------
102
104
  // batch — dispatches over this file's own subcommand catalogue, which is why
@@ -34,8 +34,7 @@
34
34
  */
35
35
 
36
36
  import { randomUUID } from 'node:crypto';
37
- import { tmpdir } from 'node:os';
38
- import { join } from 'node:path';
37
+ import { sessionProfileDirPath } from '../browser/profile-dir.js';
39
38
  import { SESSION_NAME_RE } from '../browser/ref-cache.js';
40
39
  import type { CdpClient, ElementRef, SessionRecord } from '../index.js';
41
40
  import { output } from './output.js';
@@ -254,6 +253,7 @@ export async function adoptLegacySession(
254
253
  launched: legacy.launched,
255
254
  pid: legacy.pid,
256
255
  userDataDir: legacy.userDataDir,
256
+ ownsUserDataDir: legacy.ownsUserDataDir,
257
257
  savedAt: legacy.savedAt,
258
258
  });
259
259
  await browser.removeLegacySessionRecord();
@@ -290,8 +290,10 @@ export async function launchSessionBrowser(
290
290
  port: 0,
291
291
  headless: opts.headless,
292
292
  // Chrome reports the port it bound inside this directory, so it must
293
- // belong to this session alone.
294
- userDataDir: join(tmpdir(), `monomind-browse-${process.pid}-${randomUUID().slice(0, 8)}`),
293
+ // belong to this session alone — and it is deleted when the session
294
+ // closes (#395).
295
+ userDataDir: sessionProfileDirPath(randomUUID().slice(0, 8)),
296
+ ownsUserDataDir: true,
295
297
  });
296
298
  await recordSession(browser, port, opts.name);
297
299
  ensureSignalCleanupHandlers();
@@ -324,6 +326,7 @@ async function recordSession(
324
326
  await browser.saveSessionRecord(port, {
325
327
  pid,
326
328
  userDataDir: browser.getLaunchedUserDataDir(port),
329
+ ownsUserDataDir: browser.ownsLaunchedUserDataDir(port),
327
330
  name: name ?? (await browser.loadSessionRecord(port))?.name,
328
331
  });
329
332
  return;
@@ -337,6 +340,7 @@ async function recordSession(
337
340
  launched: existing?.launched,
338
341
  pid: existing?.pid,
339
342
  userDataDir: existing?.userDataDir,
343
+ ownsUserDataDir: existing?.ownsUserDataDir,
340
344
  name: name ?? existing?.name,
341
345
  });
342
346
  }
@@ -373,6 +377,20 @@ export async function ensureConnected(port: number, targetId?: string) {
373
377
  return { client: session.client!, sessionId: session.sessionId, targetId: session.targetId };
374
378
  }
375
379
 
380
+ /**
381
+ * Drop this process's CDP connection and nothing else: the browser, its
382
+ * session record and the port pin all stay, so the next command reconnects to
383
+ * the same session. An embedder that lets the event loop drain instead of
384
+ * calling process.exit() (monomind's CLI, ADR-R001) calls this once a
385
+ * subcommand is done — an open websocket otherwise holds the process until
386
+ * its force-exit watchdog (#408).
387
+ */
388
+ export function disconnectSession(): void {
389
+ session.client?.close();
390
+ session.client = null;
391
+ session.sessionId = '';
392
+ }
393
+
376
394
  // Each CLI invocation is a fresh process, so the in-memory session.refs Map built by
377
395
  // a prior `snapshot` command is gone by the time a later `find`/`click`/etc.
378
396
  // command runs. Rehydrate it from the on-disk ref cache (written by