@monoes/monobrowse 1.0.21 → 1.0.24

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 (39) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.js +94 -1
  2. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
  3. package/dist/src/__tests__/cli-version.test.d.ts +20 -0
  4. package/dist/src/__tests__/cli-version.test.d.ts.map +1 -0
  5. package/dist/src/__tests__/cli-version.test.js +45 -0
  6. package/dist/src/__tests__/cli-version.test.js.map +1 -0
  7. package/dist/src/__tests__/session-discovery.test.js +81 -1
  8. package/dist/src/__tests__/session-discovery.test.js.map +1 -1
  9. package/dist/src/browser/ref-cache.d.ts +6 -0
  10. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  11. package/dist/src/browser/ref-cache.js +4 -0
  12. package/dist/src/browser/ref-cache.js.map +1 -1
  13. package/dist/src/cli/attention-wall.d.ts +12 -0
  14. package/dist/src/cli/attention-wall.d.ts.map +1 -0
  15. package/dist/src/cli/attention-wall.js +166 -0
  16. package/dist/src/cli/attention-wall.js.map +1 -0
  17. package/dist/src/cli/commands-session.d.ts.map +1 -1
  18. package/dist/src/cli/commands-session.js +24 -11
  19. package/dist/src/cli/commands-session.js.map +1 -1
  20. package/dist/src/cli/commands.d.ts.map +1 -1
  21. package/dist/src/cli/commands.js +21 -8
  22. package/dist/src/cli/commands.js.map +1 -1
  23. package/dist/src/cli/session.d.ts +17 -3
  24. package/dist/src/cli/session.d.ts.map +1 -1
  25. package/dist/src/cli/session.js +72 -162
  26. package/dist/src/cli/session.js.map +1 -1
  27. package/dist/src/cli.js +3 -1
  28. package/dist/src/cli.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/browse-concurrency.test.ts +106 -1
  32. package/src/__tests__/cli-version.test.ts +50 -0
  33. package/src/__tests__/session-discovery.test.ts +112 -2
  34. package/src/browser/ref-cache.ts +16 -1
  35. package/src/cli/attention-wall.ts +202 -0
  36. package/src/cli/commands-session.ts +25 -12
  37. package/src/cli/commands.ts +22 -8
  38. package/src/cli/session.ts +78 -198
  39. package/src/cli.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.21",
3
+ "version": "1.0.24",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
- import { afterEach, describe, expect, it } from 'vitest';
21
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
22
22
  import { CHROME_EXECUTABLES } from '../browser/types.js';
23
23
 
24
24
  const here = dirname(fileURLToPath(import.meta.url));
@@ -100,7 +100,51 @@ afterEach(async () => {
100
100
  }
101
101
  });
102
102
 
103
+ /**
104
+ * Start Chrome once, outside any trial, and let it finish. A runner's first
105
+ * Chrome start pays for paging the binary and its libraries in and building
106
+ * the font cache; two of them at once in trial 1 took longer than `open`'s
107
+ * 10 s CDP wait on CI, while every later trial passed. `--dump-dom` exits on
108
+ * its own once the page loads, so this has no timeout of `open`'s to beat.
109
+ */
110
+ async function warmUpChrome(): Promise<void> {
111
+ let found: string;
112
+ try {
113
+ found = execFileSync('which', ['google-chrome', 'chromium-browser', 'chromium'], {
114
+ encoding: 'utf8',
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ });
117
+ } catch (err) {
118
+ // `which` exits non-zero when any one name is missing; the rest still print.
119
+ found = String((err as { stdout?: string }).stdout ?? '');
120
+ }
121
+ const chrome = CHROME_EXECUTABLES.find((p) => existsSync(p)) ?? found.split('\n')[0];
122
+ if (!chrome) return;
123
+ const profile = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-warmup-'));
124
+ try {
125
+ await new Promise<void>((resolve) => {
126
+ execFile(
127
+ chrome,
128
+ [
129
+ '--headless=new',
130
+ `--user-data-dir=${profile}`,
131
+ '--no-first-run',
132
+ ...(process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage'] : []),
133
+ '--dump-dom',
134
+ 'about:blank',
135
+ ],
136
+ { timeout: 60_000 },
137
+ () => resolve(),
138
+ );
139
+ });
140
+ } finally {
141
+ await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
142
+ }
143
+ }
144
+
103
145
  describe.skipIf(!runnable)('#318 concurrent browse sessions (real processes)', () => {
146
+ beforeAll(warmUpChrome, 90_000);
147
+
104
148
  for (const trial of [1, 2, 3, 4, 5]) {
105
149
  it(`trial ${trial}: two bare \`open\` calls each get their own browser, and each close kills only its own`, async () => {
106
150
  const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-'));
@@ -231,3 +275,64 @@ describe.skipIf(!runnable)('#318 adoption of a pre-#318 session (real processes)
231
275
  expect(await cdpAlive(legacyPort)).toBe(false);
232
276
  }, 90_000);
233
277
  });
278
+
279
+ /**
280
+ * `--session <name>` is documented (browse-qa, browse-electron skills) as a
281
+ * way to drive several browsers side by side. It used to be a no-op outside
282
+ * `open`, where it only restored saved state and failed when none existed —
283
+ * AFTER launching a browser — so every later command silently acted on
284
+ * whichever session was newest. Real processes, because the bug is in what a
285
+ * later, separate CLI invocation resolves.
286
+ */
287
+ describe.skipIf(!runnable)('--session named sessions (real processes)', () => {
288
+ beforeAll(warmUpChrome, 90_000);
289
+
290
+ it('two named sessions stay apart, a bare command never lands on them, and an unknown name fails', async () => {
291
+ const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-named-'));
292
+ dirs.push(root);
293
+ const pageA = join(root, 'a.html');
294
+ const pageB = join(root, 'b.html');
295
+ await writeFile(pageA, '<!doctype html><title>Page A</title><h1>a</h1>');
296
+ await writeFile(pageB, '<!doctype html><title>Page B</title><h1>b</h1>');
297
+
298
+ const openA = await runCli(['open', `file://${pageA}`, '--session', 'ref'], root, root);
299
+ expect(openA.code, `open ref failed: ${openA.stdout}${openA.stderr}`).toBe(0);
300
+ const openB = await runCli(['open', `file://${pageB}`, '--session', 'build'], root, root);
301
+ expect(openB.code, `open build failed: ${openB.stdout}${openB.stderr}`).toBe(0);
302
+ const portA = reportedPort(openA);
303
+ const portB = reportedPort(openB);
304
+ expect(portA).not.toBeNull();
305
+ expect(portA).not.toBe(portB);
306
+
307
+ // 'build' is the newest session, yet --session ref reaches 'ref'.
308
+ const titleA = await runCli(['get', 'title', '--session', 'ref'], root, root);
309
+ expect(titleA.code, `get title ref failed: ${titleA.stdout}${titleA.stderr}`).toBe(0);
310
+ expect(titleA.stdout).toContain('Page A');
311
+ const titleB = await runCli(['get', 'title', '--session', 'build'], root, root);
312
+ expect(titleB.stdout).toContain('Page B');
313
+
314
+ // Re-opening a name navigates that session's own browser.
315
+ const reopen = await runCli(['open', `file://${pageB}`, '--session', 'ref'], root, root);
316
+ expect(reopen.code, `re-open ref failed: ${reopen.stdout}${reopen.stderr}`).toBe(0);
317
+ expect(reportedPort(reopen)).toBe(portA);
318
+
319
+ // An unknown name is an error, not a silent fallback to the newest session.
320
+ const unknown = await runCli(['get', 'title', '--session', 'nope'], root, root);
321
+ expect(unknown.code).not.toBe(0);
322
+ expect(`${unknown.stdout}${unknown.stderr}`).toMatch(/No live browse session named "nope"/);
323
+
324
+ // A bare command starts its own browser instead of joining a named one.
325
+ const bare = await runCli(['get', 'title'], root, root);
326
+ expect(bare.code, `bare get title failed: ${bare.stdout}${bare.stderr}`).toBe(0);
327
+ expect(bare.stdout).not.toContain('Page');
328
+
329
+ // close --session ends exactly that session.
330
+ const closeA = await runCli(['close', '--session', 'ref'], root, root);
331
+ expect(closeA.code, `close ref failed: ${closeA.stdout}${closeA.stderr}`).toBe(0);
332
+ expect(await cdpAlive(portA!)).toBe(false);
333
+ expect(await cdpAlive(portB!)).toBe(true);
334
+ await runCli(['close', '--session', 'build'], root, root);
335
+ expect(await cdpAlive(portB!)).toBe(false);
336
+ await runCli(['close'], root, root);
337
+ }, 120_000);
338
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `monobrowse --version` crashed instead of printing a version. (#320)
3
+ *
4
+ * `src/cli.ts` read the manifest with `_require('../package.json')`. That path
5
+ * is right for the SOURCE file — `src/cli.ts` sits one level under the package
6
+ * root — and wrong for the only file that ever runs it. The package compiles
7
+ * with `rootDir: "."`, so `src/cli.ts` emits to `dist/src/cli.js`, and
8
+ * `createRequire(import.meta.url)` resolves relative to THAT file: `../` is
9
+ * `dist/`, which holds no package.json. Every invocation of the standalone
10
+ * binary died with `Cannot find module '../package.json'`, in the repo and
11
+ * from a clean install alike.
12
+ *
13
+ * WHY THIS IS A SUBPROCESS TEST. The bug lives entirely in the emitted file's
14
+ * position on disk. Importing anything from `src/` re-resolves the path from
15
+ * the source tree, where the broken version was already correct — an in-process
16
+ * test would have passed against the bug. Only spawning the built `dist/src/
17
+ * cli.js`, the exact file `bin.monobrowse` points at, can observe it.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { describe, expect, it } from 'vitest';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const pkgRoot = join(here, '..', '..');
28
+ const distCli = join(pkgRoot, 'dist', 'src', 'cli.js');
29
+ const hasBuild = existsSync(distCli);
30
+
31
+ describe.skipIf(!hasBuild)('the built monobrowse binary reports its version', () => {
32
+ const declared = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
33
+ .version as string;
34
+
35
+ for (const flag of ['--version', '-V']) {
36
+ it(`\`monobrowse ${flag}\` exits 0 and prints the package version`, () => {
37
+ // THE BUG: this threw — exit 1, `✗ Cannot find module '../package.json'`.
38
+ const out = execFileSync('node', [distCli, flag], { encoding: 'utf8', stdio: 'pipe' });
39
+
40
+ expect(out.trim()).toBe(declared);
41
+ expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/);
42
+ }, 15_000);
43
+ }
44
+ });
45
+
46
+ describe.skipIf(hasBuild)('monobrowse --version subprocess test', () => {
47
+ it('is skipped because dist/ is not built (run `npm run build`)', () => {
48
+ expect(hasBuild).toBe(false);
49
+ });
50
+ });
@@ -30,8 +30,14 @@ afterEach(async () => {
30
30
  async function load() {
31
31
  vi.resetModules();
32
32
  const store = await import('../browser/ref-cache.js');
33
- const { resolveLiveSession, adoptLegacySession, pinnedPort, applySessionPortFlag, session } =
34
- await import('../cli/session.js');
33
+ const {
34
+ resolveLiveSession,
35
+ adoptLegacySession,
36
+ pinnedPort,
37
+ applySessionPortFlag,
38
+ applySessionNameFlag,
39
+ session,
40
+ } = await import('../cli/session.js');
35
41
  // The slice of the browser namespace resolveLiveSession uses.
36
42
  const browser = {
37
43
  listSessionRecords: store.listSessionRecords,
@@ -49,6 +55,7 @@ async function load() {
49
55
  adoptLegacySession,
50
56
  pinnedPort,
51
57
  applySessionPortFlag,
58
+ applySessionNameFlag,
52
59
  session,
53
60
  };
54
61
  }
@@ -257,3 +264,106 @@ describe('#318 --port session selector', () => {
257
264
  expect(session.port).toBe(41111);
258
265
  });
259
266
  });
267
+
268
+ describe('--session names a live session', () => {
269
+ /** Record sessions oldest → newest, so newest-first order is deterministic. */
270
+ async function recordInOrder(
271
+ store: Awaited<ReturnType<typeof load>>['store'],
272
+ ...records: Array<{ port: number; name?: string }>
273
+ ) {
274
+ const now = Date.now();
275
+ const clock = vi.spyOn(Date, 'now');
276
+ for (const [i, r] of records.entries()) {
277
+ clock.mockReturnValue(now - (records.length - i) * 1_000);
278
+ await store.saveSessionRecord(r.port, { pid: 10 + i, name: r.name });
279
+ }
280
+ clock.mockRestore();
281
+ }
282
+
283
+ it('the record keeps its name', async () => {
284
+ const { store } = await load();
285
+ await store.saveSessionRecord(41111, { pid: 11, name: 'ref' });
286
+ expect((await store.loadSessionRecord(41111))?.name).toBe('ref');
287
+ });
288
+
289
+ it('resolves the live session with that name, not the newest one', async () => {
290
+ const { store, browser, resolveLiveSession } = await load();
291
+ await recordInOrder(
292
+ store,
293
+ { port: 41111, name: 'ref' },
294
+ { port: 42222, name: 'build' },
295
+ { port: 43333 },
296
+ );
297
+ onlyLive(41111, 42222, 43333);
298
+
299
+ expect((await resolveLiveSession(browser, { name: 'ref' }))?.port).toBe(41111);
300
+ expect((await resolveLiveSession(browser, { name: 'build' }))?.port).toBe(42222);
301
+ });
302
+
303
+ it('an unknown name resolves to none instead of falling back to another session', async () => {
304
+ const { store, browser, resolveLiveSession } = await load();
305
+ await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'build' });
306
+ onlyLive(41111, 42222);
307
+
308
+ expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
309
+ });
310
+
311
+ it('a command with no name never lands on a named session', async () => {
312
+ const { store, browser, resolveLiveSession } = await load();
313
+ await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'ref' });
314
+ onlyLive(41111, 42222);
315
+
316
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
317
+ });
318
+
319
+ it('a dead named session is dropped, and the name resolves to none', async () => {
320
+ const { store, browser, resolveLiveSession } = await load();
321
+ await recordInOrder(store, { port: 41111, name: 'ref' });
322
+ onlyLive();
323
+
324
+ expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
325
+ expect(await store.loadSessionRecord(41111)).toBeNull();
326
+ });
327
+
328
+ // Regression: a command whose flags never set `session.name` (e.g.
329
+ // `snapshot -s <name>` — `-s` is `--selector` on snapshot, not
330
+ // `--session`, see withSessionSelector in cli/commands.ts) used to find
331
+ // NO live session at all once the only live session was named, so
332
+ // ensureConnected silently launched a brand-new, unrelated headless
333
+ // Chrome — its blank "New Tab" is what a following snapshot showed
334
+ // instead of the page the named session had actually opened. With only
335
+ // one live session, named or not, a nameless lookup must still resolve to
336
+ // it: there is nothing else it could mean.
337
+ it('a command with no name resolves the sole live session even if it is named', async () => {
338
+ const { store, browser, resolveLiveSession } = await load();
339
+ await recordInOrder(store, { port: 41111, name: 'ctrl' });
340
+ onlyLive(41111);
341
+
342
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
343
+ });
344
+
345
+ // But guessing between two DIFFERENT named sessions would reintroduce the
346
+ // exact cross-session bleed #318 fixed — refuse instead of picking one.
347
+ it('a command with no name refuses to guess between two live named sessions', async () => {
348
+ const { store, browser, resolveLiveSession } = await load();
349
+ await recordInOrder(store, { port: 41111, name: 'alpha' }, { port: 42222, name: 'beta' });
350
+ onlyLive(41111, 42222);
351
+
352
+ expect(await resolveLiveSession(browser)).toBeNull();
353
+ });
354
+
355
+ it('--session sets the process session name; a missing flag leaves it alone', async () => {
356
+ const { applySessionNameFlag, session } = await load();
357
+ applySessionNameFlag({ _: [], session: 'ref' });
358
+ expect(session.name).toBe('ref');
359
+ applySessionNameFlag({ _: [] });
360
+ expect(session.name).toBe('ref');
361
+ });
362
+
363
+ it('rejects a session name that is not a plain identifier', async () => {
364
+ const { applySessionNameFlag } = await load();
365
+ for (const bad of ['', '../x', 'a b', 'x'.repeat(65)]) {
366
+ expect(() => applySessionNameFlag({ _: [], session: bad })).toThrow(/Invalid session name/);
367
+ }
368
+ });
369
+ });
@@ -119,8 +119,14 @@ export interface SessionRecord {
119
119
  pid?: number;
120
120
  userDataDir?: string;
121
121
  savedAt?: number;
122
+ /** Set by `--session <name>`: only commands naming it resolve this session,
123
+ * and commands naming none never land on it. */
124
+ name?: string;
122
125
  }
123
126
 
127
+ /** A `--session` name: a plain identifier, safe as a label and in messages. */
128
+ export const SESSION_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
129
+
124
130
  /**
125
131
  * Record a browse session under its own port, so concurrent sessions in the
126
132
  * same working directory coexist instead of overwriting one shared handle.
@@ -132,7 +138,13 @@ export interface SessionRecord {
132
138
  */
133
139
  export async function saveSessionRecord(
134
140
  port: number,
135
- opts?: { launched?: boolean; pid?: number; userDataDir?: string; savedAt?: number },
141
+ opts?: {
142
+ launched?: boolean;
143
+ pid?: number;
144
+ userDataDir?: string;
145
+ savedAt?: number;
146
+ name?: string;
147
+ },
136
148
  ): Promise<void> {
137
149
  try {
138
150
  await mkdir(SESSIONS_DIR, { recursive: true });
@@ -150,6 +162,7 @@ export async function saveSessionRecord(
150
162
  pid: opts?.pid,
151
163
  userDataDir: opts?.userDataDir,
152
164
  savedAt: opts?.savedAt ?? Date.now(),
165
+ name: opts?.name,
153
166
  }),
154
167
  );
155
168
  } catch {
@@ -235,6 +248,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
235
248
  pid?: unknown;
236
249
  userDataDir?: unknown;
237
250
  savedAt?: unknown;
251
+ name?: unknown;
238
252
  };
239
253
  if (
240
254
  typeof data.port !== 'number' ||
@@ -253,5 +267,6 @@ function parseSessionRecord(raw: string): SessionRecord | null {
253
267
  : undefined,
254
268
  userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
255
269
  savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
270
+ name: typeof data.name === 'string' && SESSION_NAME_RE.test(data.name) ? data.name : undefined,
256
271
  };
257
272
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Login / CAPTCHA walls — how `open` notices one, and the round trip to a
3
+ * visible browser window that lets a person get past it before the session
4
+ * carries on headless with the cookies and localStorage they earned.
5
+ *
6
+ * Split out of session.ts, which keeps the session runtime itself.
7
+ */
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import type { CdpClient } from '../index.js';
13
+ import { output } from './output.js';
14
+ import { getBrowser, session } from './session.js';
15
+
16
+ // URL patterns that signal an unambiguous login/auth wall.
17
+ // Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
18
+ // (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
19
+ // DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
20
+ const ATTENTION_URL_RE =
21
+ /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
22
+
23
+ export async function detectAttentionNeeded(
24
+ client: CdpClient,
25
+ sessionId: string,
26
+ url: string,
27
+ ): Promise<'login' | 'captcha' | null> {
28
+ if (ATTENTION_URL_RE.test(url)) return 'login';
29
+ try {
30
+ const hasPassword = await client
31
+ .send<{ result: { value: boolean } }>(
32
+ 'Runtime.evaluate',
33
+ {
34
+ expression: '!!document.querySelector("input[type=password]")',
35
+ returnByValue: true,
36
+ },
37
+ sessionId,
38
+ )
39
+ .then((r) => r.result?.value === true)
40
+ .catch(() => false);
41
+ if (hasPassword) return 'login';
42
+
43
+ const hasCaptcha = await client
44
+ .send<{ result: { value: boolean } }>(
45
+ 'Runtime.evaluate',
46
+ {
47
+ expression:
48
+ '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
49
+ returnByValue: true,
50
+ },
51
+ sessionId,
52
+ )
53
+ .then((r) => r.result?.value === true)
54
+ .catch(() => false);
55
+ if (hasCaptcha) return 'captcha';
56
+ } catch {
57
+ /* ignore CDP errors */
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function waitForEnter(): Promise<void> {
63
+ if (!process.stdin.isTTY) {
64
+ // Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
65
+ // Auto-continue after a short grace period so automation isn't blocked.
66
+ output.printWarning(
67
+ 'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
68
+ );
69
+ return new Promise((resolve) => setTimeout(resolve, 30_000));
70
+ }
71
+ return new Promise((resolve) => {
72
+ process.stdin.resume();
73
+ process.stdin.setEncoding('utf8');
74
+ const onData = () => {
75
+ process.stdin.pause();
76
+ process.stdin.off('data', onData);
77
+ resolve();
78
+ };
79
+ process.stdin.once('data', onData);
80
+ });
81
+ }
82
+
83
+ export async function switchToHeaded(url: string, port: number): Promise<void> {
84
+ const browser = await getBrowser();
85
+
86
+ // Snapshot cookies from current headless session before closing
87
+ let savedCookies: unknown[] = [];
88
+ if (session.client && session.sessionId) {
89
+ try {
90
+ savedCookies = await browser.getCookies(session.client, session.sessionId);
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ browser.teardownRouteInterception(session.sessionId);
95
+ browser.stopRequestCapture(session.sessionId);
96
+ browser.teardownDialogHandling(session.sessionId);
97
+ browser.teardownConsoleCapture(session.sessionId);
98
+ session.client.close();
99
+ session.client = null;
100
+ session.sessionId = '';
101
+ session.parentSessionId = '';
102
+ session.targetId = '';
103
+ }
104
+
105
+ // Kernel-assigned free port and a profile of its own, so this temporary
106
+ // headed window cannot collide with any other session (#318) — a
107
+ // neighbouring port (port + 10) could well be another session's.
108
+ let headedPort = 0;
109
+ try {
110
+ headedPort = await browser.launchBrowser({
111
+ port: 0,
112
+ headless: false,
113
+ userDataDir: join(
114
+ tmpdir(),
115
+ `monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
116
+ ),
117
+ });
118
+ } catch (err) {
119
+ // Headed launch failed (no display, locked down env). Restore headless and rethrow.
120
+ session.port = await browser.launchBrowser({ port, headless: true });
121
+ const fallback = await browser.connectToTarget(session.port);
122
+ session.client = fallback.client;
123
+ session.sessionId = fallback.sessionId;
124
+ session.targetId = fallback.target.id;
125
+ session.refs = new Map();
126
+ throw new Error(
127
+ `Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
128
+ );
129
+ }
130
+ const conn = await browser.connectToTarget(headedPort);
131
+ session.client = conn.client;
132
+ session.sessionId = conn.sessionId;
133
+ session.targetId = conn.target.id;
134
+ session.refs = new Map();
135
+
136
+ if (savedCookies.length) {
137
+ try {
138
+ await browser.setCookies(
139
+ session.client,
140
+ session.sessionId,
141
+ savedCookies as Parameters<typeof browser.setCookies>[2],
142
+ );
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ }
147
+ await browser.openUrl(session.client, session.sessionId, url);
148
+
149
+ output.printInfo(
150
+ 'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
151
+ );
152
+ await waitForEnter();
153
+
154
+ // Capture post-auth cookies
155
+ const authCookies = await browser
156
+ .getCookies(session.client, session.sessionId)
157
+ .catch(() => [] as unknown[]);
158
+ const authLocalStorage = await browser
159
+ .getLocalStorage(session.client, session.sessionId)
160
+ .catch(() => ({}) as Record<string, string>);
161
+
162
+ // Close headed session — actually terminate the underlying Chrome process
163
+ // (Browser.close CDP command, PID-kill fallback), not just our CDP client
164
+ // connection, so the visible authenticated window doesn't linger forever.
165
+ browser.teardownRouteInterception(session.sessionId);
166
+ browser.stopRequestCapture(session.sessionId);
167
+ browser.teardownDialogHandling(session.sessionId);
168
+ browser.teardownConsoleCapture(session.sessionId);
169
+ await browser.closeBrowser(session.client, headedPort);
170
+ session.client.close();
171
+ session.client = null;
172
+ session.sessionId = '';
173
+ session.parentSessionId = '';
174
+ session.targetId = '';
175
+
176
+ // Relaunch headless and restore session
177
+ session.port = await browser.launchBrowser({ port, headless: true });
178
+ const headlessConn = await browser.connectToTarget(session.port);
179
+ session.client = headlessConn.client;
180
+ session.sessionId = headlessConn.sessionId;
181
+ session.targetId = headlessConn.target.id;
182
+ session.refs = new Map();
183
+
184
+ if (authCookies.length) {
185
+ try {
186
+ await browser.setCookies(
187
+ session.client,
188
+ session.sessionId,
189
+ authCookies as Parameters<typeof browser.setCookies>[2],
190
+ );
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ }
195
+ if (authLocalStorage && Object.keys(authLocalStorage).length) {
196
+ try {
197
+ await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+ }
202
+ }
@@ -8,17 +8,16 @@
8
8
  * differently: it must never kill a Chrome it did not launch.
9
9
  */
10
10
 
11
+ import { detectAttentionNeeded, switchToHeaded } from './attention-wall.js';
11
12
  import { output } from './output.js';
12
13
  import {
13
14
  adoptLegacySession,
14
- detectAttentionNeeded,
15
15
  ensureConnected,
16
16
  getBrowser,
17
17
  launchSessionBrowser,
18
18
  pinnedPort,
19
19
  resolveLiveSession,
20
20
  session,
21
- switchToHeaded,
22
21
  } from './session.js';
23
22
  import type { Command, CommandContext, CommandResult } from './types.js';
24
23
 
@@ -39,7 +38,13 @@ export const openCommand: Command = {
39
38
  description: 'Force visible browser window',
40
39
  default: false,
41
40
  },
42
- { name: 'session', short: 's', type: 'string', description: 'Session name to restore' },
41
+ {
42
+ name: 'session',
43
+ short: 's',
44
+ type: 'string',
45
+ description:
46
+ 'Named session: re-open the live one with this name or start it, and restore saved state of that name if any',
47
+ },
43
48
  { name: 'state', type: 'string', description: 'State file to load' },
44
49
  ],
45
50
  action: async (ctx: CommandContext): Promise<CommandResult> => {
@@ -48,10 +53,14 @@ export const openCommand: Command = {
48
53
 
49
54
  // No --port means "start a session of my own on a free port" (#318) —
50
55
  // never "join whatever is on 9222", which is what made two uncoordinated
51
- // `open` calls race for one browser and one profile directory.
52
- const port = pinnedPort(ctx.flags);
53
- const forceHeaded = ctx.flags.headed as boolean;
56
+ // `open` calls race for one browser and one profile directory. A
57
+ // --session name re-opens the live session of that name, if there is one.
54
58
  const browser = await getBrowser();
59
+ const name = session.name || undefined;
60
+ const port =
61
+ pinnedPort(ctx.flags) ??
62
+ (name ? (await resolveLiveSession(browser, { name }))?.port : undefined);
63
+ const forceHeaded = ctx.flags.headed as boolean;
55
64
 
56
65
  if (session.client) {
57
66
  const prevSid = session.sessionId;
@@ -91,7 +100,7 @@ export const openCommand: Command = {
91
100
 
92
101
  // Starts this session's own browser (and records it) unless --port named
93
102
  // one to attach to — see the session rule in session.ts.
94
- session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
103
+ session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded, name });
95
104
  const conn = await browser.connectToTarget(session.port);
96
105
  session.client = conn.client;
97
106
  session.sessionId = conn.sessionId;
@@ -107,8 +116,10 @@ export const openCommand: Command = {
107
116
  }
108
117
  if (ctx.flags.state) {
109
118
  await browser.loadStateFile(session.client, session.sessionId, ctx.flags.state as string);
110
- } else if (ctx.flags.session) {
111
- await browser.loadSession(session.client, session.sessionId, ctx.flags.session as string);
119
+ } else if (name && (await browser.listSessions()).includes(name)) {
120
+ // Saved state of the same name (`state save <name>`) is restored; a
121
+ // name with none is simply a fresh named session.
122
+ await browser.loadSession(session.client, session.sessionId, name);
112
123
  }
113
124
 
114
125
  await browser.openUrl(session.client, session.sessionId, url);
@@ -228,7 +239,7 @@ export const closeCommand: Command = {
228
239
  port: pinned,
229
240
  launched: true,
230
241
  })
231
- : await resolveLiveSession(browser, { strict: false });
242
+ : await resolveLiveSession(browser, { strict: false, name: session.name || undefined });
232
243
  if (persisted?.launched) {
233
244
  try {
234
245
  const conn = await browser.connectToTarget(persisted.port);
@@ -271,7 +282,9 @@ export const closeCommand: Command = {
271
282
  output.printInfo(
272
283
  persisted
273
284
  ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
274
- : 'No active browser session',
285
+ : session.name
286
+ ? `No live browse session named "${session.name}"`
287
+ : 'No active browser session',
275
288
  );
276
289
  }
277
290
  if (persisted) {
@@ -370,7 +383,7 @@ export const connectCommand: Command = {
370
383
  // launches a browser of its own instead of reusing this session.
371
384
  // launched:false marks this browser as someone else's — close must never
372
385
  // kill it, and a dead endpoint must not be silently relaunched.
373
- await browser.saveSessionRecord(port, { launched: false });
386
+ await browser.saveSessionRecord(port, { launched: false, name: session.name || undefined });
374
387
  const url = await browser.getCurrentUrl(session.client, session.sessionId);
375
388
  const title = await browser.getCurrentTitle(session.client, session.sessionId);
376
389
  output.printSuccess(`Connected: ${title} (${url})`);