@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
@@ -90,7 +90,7 @@ import {
90
90
  } from './commands-trace.js';
91
91
  import { output } from './output.js';
92
92
  import { createPlatformCommand } from './platform.js';
93
- import { applySessionPortFlag } from './session.js';
93
+ import { applySessionNameFlag, applySessionPortFlag } from './session.js';
94
94
  import type { Command, CommandContext, CommandResult } from './types.js';
95
95
 
96
96
  // Re-exported for direct unit testing — these were exported from this file
@@ -230,16 +230,33 @@ const PORT_OPTION = {
230
230
  description: 'Act on the browse session on this CDP port (default: the newest live session)',
231
231
  } as const;
232
232
 
233
+ const SESSION_OPTION = {
234
+ name: 'session',
235
+ short: 's',
236
+ type: 'string',
237
+ description: 'Act on the live browse session with this name (open/connect start it)',
238
+ } as const;
239
+
233
240
  function withSessionSelector(cmd: Command): Command {
234
241
  const action = cmd.action;
242
+ const options = cmd.options?.some((o) => o.name === 'port')
243
+ ? (cmd.options ?? [])
244
+ : [...(cmd.options ?? []), { ...PORT_OPTION }];
235
245
  return {
236
246
  ...cmd,
237
- options: cmd.options?.some((o) => o.name === 'port')
238
- ? cmd.options
239
- : [...(cmd.options ?? []), { ...PORT_OPTION }],
247
+ // `snapshot`/`scroll` already use -s for --selector: long form only there.
248
+ options: options.some((o) => o.name === 'session')
249
+ ? options
250
+ : [
251
+ ...options,
252
+ options.some((o) => o.short === 's')
253
+ ? { ...SESSION_OPTION, short: undefined }
254
+ : { ...SESSION_OPTION },
255
+ ],
240
256
  action: action
241
257
  ? async (ctx: CommandContext) => {
242
258
  applySessionPortFlag(ctx.flags);
259
+ applySessionNameFlag(ctx.flags);
243
260
  return action(ctx);
244
261
  }
245
262
  : undefined,
@@ -324,10 +341,7 @@ const browseCommand: Command = {
324
341
  name: 'browse',
325
342
  description: 'Native browser automation via Chrome DevTools Protocol',
326
343
  subcommands,
327
- options: [
328
- { ...PORT_OPTION },
329
- { name: 'session', short: 's', type: 'string', description: 'Named session to use' },
330
- ],
344
+ options: [{ ...PORT_OPTION }, { ...SESSION_OPTION }],
331
345
  examples: [
332
346
  { command: 'monomind browse open https://example.com', description: 'Open a URL' },
333
347
  {
@@ -23,6 +23,11 @@
23
23
  * its own, exactly as `open` would.
24
24
  * - Any later command with `--port N` acts on that session, which is how a
25
25
  * concurrent caller pins the one `open` handed it.
26
+ * - `--session <name>` is the same pin by name: `open`/`connect` record the
27
+ * name on the session they start (or re-open the live one of that name),
28
+ * every other command acts on the live session of that name and fails
29
+ * when there is none. A command with no name never resolves a named
30
+ * session, so named sessions only ever see their own commands.
26
31
  * - `close` ends exactly the session it resolved — one browser, one record.
27
32
  *
28
33
  * Split out of commands.ts, which is now the command catalogue.
@@ -31,6 +36,7 @@
31
36
  import { randomUUID } from 'node:crypto';
32
37
  import { tmpdir } from 'node:os';
33
38
  import { join } from 'node:path';
39
+ import { SESSION_NAME_RE } from '../browser/ref-cache.js';
34
40
  import type { CdpClient, ElementRef, SessionRecord } from '../index.js';
35
41
  import { output } from './output.js';
36
42
  import type { ParsedFlags } from './types.js';
@@ -49,6 +55,8 @@ export const session: {
49
55
  sessionId: string;
50
56
  targetId: string;
51
57
  port: number;
58
+ /** `--session <name>`, or '' for the unnamed sessions. */
59
+ name: string;
52
60
  refs: Map<string, ElementRef>;
53
61
  /** Saved parent sessionId when inside an iframe — restored by `frame main`. */
54
62
  parentSessionId: string;
@@ -57,6 +65,7 @@ export const session: {
57
65
  sessionId: '',
58
66
  targetId: '',
59
67
  port: UNPINNED,
68
+ name: '',
60
69
  refs: new Map(),
61
70
  parentSessionId: '',
62
71
  };
@@ -86,6 +95,29 @@ export function applySessionPortFlag(flags: ParsedFlags): void {
86
95
  if (pinned !== undefined) session.port = pinned;
87
96
  }
88
97
 
98
+ /**
99
+ * Select the named session given by `--session`. Like the port pin, it only
100
+ * ever sets: a batched command without the flag stays on the session an
101
+ * earlier command in this process named.
102
+ */
103
+ export function applySessionNameFlag(flags: ParsedFlags): void {
104
+ const raw = flags.session;
105
+ if (raw === undefined) return;
106
+ if (typeof raw !== 'string' || !SESSION_NAME_RE.test(raw)) {
107
+ throw new Error(
108
+ `Invalid session name "${String(raw)}" — use 1-64 letters, digits, '.', '_' or '-'.`,
109
+ );
110
+ }
111
+ session.name = raw;
112
+ }
113
+
114
+ /** The error for a `--session` name no live browser answers to. */
115
+ export function noNamedSessionError(name: string): Error {
116
+ return new Error(
117
+ `No live browse session named "${name}" — start one with: monomind browse open <url> --session ${name}`,
118
+ );
119
+ }
120
+
89
121
  export async function getBrowser() {
90
122
  return import('../index.js');
91
123
  }
@@ -140,11 +172,32 @@ export function ensureSignalCleanupHandlers(): void {
140
172
  //
141
173
  // Records whose browser is gone are dropped as we pass them (self-heal): a
142
174
  // crashed or manually-killed Chrome must not wedge every later command.
175
+ //
176
+ // `name` selects the session recorded under that `--session` name; without
177
+ // one, only unnamed sessions are candidates — UNLESS there is exactly one
178
+ // other live session, named or not (see `soleOtherLive` below): a command
179
+ // that named nothing still has to resolve to *something* when there is only
180
+ // one browser to mean, or every command run after a named-only `open` (any
181
+ // flag combination that leaves `session.name` unset — e.g. `snapshot`'s `-s`
182
+ // is `--selector`, not `--session`, see withSessionSelector) would silently
183
+ // miss the live named session and launch a brand-new, unrelated Chrome
184
+ // instead — the process this file exists to prevent (#318), just with an
185
+ // empty new tab standing in for the "wrong browser" (issue: named-session
186
+ // snapshot showed Chrome's New Tab instead of the opened page). Ambiguity
187
+ // (two or more other live sessions) is still refused: guessing among several
188
+ // named browsers is exactly the cross-session bleed #318 fixed.
143
189
  export async function resolveLiveSession(
144
190
  browser: Awaited<ReturnType<typeof getBrowser>>,
145
- opts: { strict?: boolean } = {},
191
+ opts: { strict?: boolean; name?: string } = {},
146
192
  ): Promise<SessionRecord | null> {
193
+ const soleOtherLive: SessionRecord[] = [];
147
194
  for (const record of await browser.listSessionRecords()) {
195
+ if ((record.name ?? '') !== (opts.name ?? '')) {
196
+ if (opts.name === undefined && soleOtherLive.length < 2 && (await cdpAnswers(record.port))) {
197
+ soleOtherLive.push(record);
198
+ }
199
+ continue;
200
+ }
148
201
  if (await cdpAnswers(record.port)) return record;
149
202
  await browser.removeSessionRecord(record.port);
150
203
  await browser.clearRefCache(record.port);
@@ -159,10 +212,12 @@ export async function resolveLiveSession(
159
212
  );
160
213
  }
161
214
  }
162
- // Nothing native is live. A session opened by a pre-#318 CLI in this
163
- // directory may still be — it is the oldest possible candidate, so it is
164
- // considered last.
165
- return adoptLegacySession(browser);
215
+ if (opts.name === undefined && soleOtherLive.length === 1) return soleOtherLive[0];
216
+ // Nothing native is live (or matched). A session opened by a pre-#318 CLI
217
+ // in this directory may still be — it is the oldest possible candidate, so
218
+ // it is considered last. It predates names, so a named lookup never adopts
219
+ // it.
220
+ return opts.name ? null : adoptLegacySession(browser);
166
221
  }
167
222
 
168
223
  /**
@@ -227,7 +282,7 @@ async function cdpAnswers(port: number): Promise<boolean> {
227
282
  */
228
283
  export async function launchSessionBrowser(
229
284
  browser: Awaited<ReturnType<typeof getBrowser>>,
230
- opts: { port?: number; headless: boolean },
285
+ opts: { port?: number; headless: boolean; name?: string },
231
286
  ): Promise<number> {
232
287
  const port = opts.port
233
288
  ? await browser.launchBrowser({ port: opts.port, headless: opts.headless })
@@ -238,7 +293,7 @@ export async function launchSessionBrowser(
238
293
  // belong to this session alone.
239
294
  userDataDir: join(tmpdir(), `monomind-browse-${process.pid}-${randomUUID().slice(0, 8)}`),
240
295
  });
241
- await recordSession(browser, port);
296
+ await recordSession(browser, port, opts.name);
242
297
  ensureSignalCleanupHandlers();
243
298
  return port;
244
299
  }
@@ -256,15 +311,20 @@ export async function launchSessionBrowser(
256
311
  // {pid: undefined} on attach used to CLOBBER a real PID a previous `open`
257
312
  // had persisted for this exact port, destroying the only way a later
258
313
  // process's closeBrowser() PID-kill fallback could ever find it.
314
+ //
315
+ // `name` labels the session; without one, re-recording keeps the name the
316
+ // session already had, so a `--port` command does not strip it.
259
317
  async function recordSession(
260
318
  browser: Awaited<ReturnType<typeof getBrowser>>,
261
319
  port: number,
320
+ name?: string,
262
321
  ): Promise<void> {
263
322
  const pid = browser.getLaunchedPid(port);
264
323
  if (pid !== undefined) {
265
324
  await browser.saveSessionRecord(port, {
266
325
  pid,
267
326
  userDataDir: browser.getLaunchedUserDataDir(port),
327
+ name: name ?? (await browser.loadSessionRecord(port))?.name,
268
328
  });
269
329
  return;
270
330
  }
@@ -277,6 +337,7 @@ async function recordSession(
277
337
  launched: existing?.launched,
278
338
  pid: existing?.pid,
279
339
  userDataDir: existing?.userDataDir,
340
+ name: name ?? existing?.name,
280
341
  });
281
342
  }
282
343
 
@@ -290,9 +351,16 @@ export async function ensureConnected(port: number, targetId?: string) {
290
351
  browser.teardownConsoleCapture(session.sessionId);
291
352
  session.client.close();
292
353
  }
293
- // Pinned port, else the newest live session, else a session of our own —
294
- // a command run before any `open` still just works.
295
- const pinned = port > 0 ? port : ((await resolveLiveSession(browser))?.port ?? undefined);
354
+ // Pinned port, else the named session (which must exist), else the
355
+ // newest live unnamed session, else a session of our own — a command run
356
+ // before any `open` still just works.
357
+ let pinned: number | undefined = port > 0 ? port : undefined;
358
+ if (pinned === undefined && session.name) {
359
+ pinned = (await resolveLiveSession(browser, { name: session.name }))?.port;
360
+ if (pinned === undefined) throw noNamedSessionError(session.name);
361
+ } else if (pinned === undefined) {
362
+ pinned = (await resolveLiveSession(browser))?.port;
363
+ }
296
364
  session.port = await launchSessionBrowser(browser, { port: pinned, headless: true });
297
365
  const conn = await browser.connectToTarget(session.port, targetId);
298
366
  session.client = conn.client;
@@ -341,194 +409,6 @@ export async function hydrateRefsFromCache(
341
409
  }
342
410
  }
343
411
 
344
- // URL patterns that signal an unambiguous login/auth wall.
345
- // Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
346
- // (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
347
- // DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
348
- const ATTENTION_URL_RE =
349
- /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
350
-
351
- export async function detectAttentionNeeded(
352
- client: CdpClient,
353
- sessionId: string,
354
- url: string,
355
- ): Promise<'login' | 'captcha' | null> {
356
- if (ATTENTION_URL_RE.test(url)) return 'login';
357
- try {
358
- const hasPassword = await client
359
- .send<{ result: { value: boolean } }>(
360
- 'Runtime.evaluate',
361
- {
362
- expression: '!!document.querySelector("input[type=password]")',
363
- returnByValue: true,
364
- },
365
- sessionId,
366
- )
367
- .then((r) => r.result?.value === true)
368
- .catch(() => false);
369
- if (hasPassword) return 'login';
370
-
371
- const hasCaptcha = await client
372
- .send<{ result: { value: boolean } }>(
373
- 'Runtime.evaluate',
374
- {
375
- expression:
376
- '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
377
- returnByValue: true,
378
- },
379
- sessionId,
380
- )
381
- .then((r) => r.result?.value === true)
382
- .catch(() => false);
383
- if (hasCaptcha) return 'captcha';
384
- } catch {
385
- /* ignore CDP errors */
386
- }
387
- return null;
388
- }
389
-
390
- export function waitForEnter(): Promise<void> {
391
- if (!process.stdin.isTTY) {
392
- // Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
393
- // Auto-continue after a short grace period so automation isn't blocked.
394
- output.printWarning(
395
- 'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
396
- );
397
- return new Promise((resolve) => setTimeout(resolve, 30_000));
398
- }
399
- return new Promise((resolve) => {
400
- process.stdin.resume();
401
- process.stdin.setEncoding('utf8');
402
- const onData = () => {
403
- process.stdin.pause();
404
- process.stdin.off('data', onData);
405
- resolve();
406
- };
407
- process.stdin.once('data', onData);
408
- });
409
- }
410
-
411
- export async function switchToHeaded(url: string, port: number): Promise<void> {
412
- const browser = await getBrowser();
413
-
414
- // Snapshot cookies from current headless session before closing
415
- let savedCookies: unknown[] = [];
416
- if (session.client && session.sessionId) {
417
- try {
418
- savedCookies = await browser.getCookies(session.client, session.sessionId);
419
- } catch {
420
- /* ignore */
421
- }
422
- browser.teardownRouteInterception(session.sessionId);
423
- browser.stopRequestCapture(session.sessionId);
424
- browser.teardownDialogHandling(session.sessionId);
425
- browser.teardownConsoleCapture(session.sessionId);
426
- session.client.close();
427
- session.client = null;
428
- session.sessionId = '';
429
- session.parentSessionId = '';
430
- session.targetId = '';
431
- }
432
-
433
- // Kernel-assigned free port and a profile of its own, so this temporary
434
- // headed window cannot collide with any other session (#318) — a
435
- // neighbouring port (port + 10) could well be another session's.
436
- let headedPort = 0;
437
- try {
438
- headedPort = await browser.launchBrowser({
439
- port: 0,
440
- headless: false,
441
- userDataDir: join(
442
- tmpdir(),
443
- `monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
444
- ),
445
- });
446
- } catch (err) {
447
- // Headed launch failed (no display, locked down env). Restore headless and rethrow.
448
- session.port = await browser.launchBrowser({ port, headless: true });
449
- const fallback = await browser.connectToTarget(session.port);
450
- session.client = fallback.client;
451
- session.sessionId = fallback.sessionId;
452
- session.targetId = fallback.target.id;
453
- session.refs = new Map();
454
- throw new Error(
455
- `Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
456
- );
457
- }
458
- const conn = await browser.connectToTarget(headedPort);
459
- session.client = conn.client;
460
- session.sessionId = conn.sessionId;
461
- session.targetId = conn.target.id;
462
- session.refs = new Map();
463
-
464
- if (savedCookies.length) {
465
- try {
466
- await browser.setCookies(
467
- session.client,
468
- session.sessionId,
469
- savedCookies as Parameters<typeof browser.setCookies>[2],
470
- );
471
- } catch {
472
- /* ignore */
473
- }
474
- }
475
- await browser.openUrl(session.client, session.sessionId, url);
476
-
477
- output.printInfo(
478
- 'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
479
- );
480
- await waitForEnter();
481
-
482
- // Capture post-auth cookies
483
- const authCookies = await browser
484
- .getCookies(session.client, session.sessionId)
485
- .catch(() => [] as unknown[]);
486
- const authLocalStorage = await browser
487
- .getLocalStorage(session.client, session.sessionId)
488
- .catch(() => ({}) as Record<string, string>);
489
-
490
- // Close headed session — actually terminate the underlying Chrome process
491
- // (Browser.close CDP command, PID-kill fallback), not just our CDP client
492
- // connection, so the visible authenticated window doesn't linger forever.
493
- browser.teardownRouteInterception(session.sessionId);
494
- browser.stopRequestCapture(session.sessionId);
495
- browser.teardownDialogHandling(session.sessionId);
496
- browser.teardownConsoleCapture(session.sessionId);
497
- await browser.closeBrowser(session.client, headedPort);
498
- session.client.close();
499
- session.client = null;
500
- session.sessionId = '';
501
- session.parentSessionId = '';
502
- session.targetId = '';
503
-
504
- // Relaunch headless and restore session
505
- session.port = await browser.launchBrowser({ port, headless: true });
506
- const headlessConn = await browser.connectToTarget(session.port);
507
- session.client = headlessConn.client;
508
- session.sessionId = headlessConn.sessionId;
509
- session.targetId = headlessConn.target.id;
510
- session.refs = new Map();
511
-
512
- if (authCookies.length) {
513
- try {
514
- await browser.setCookies(
515
- session.client,
516
- session.sessionId,
517
- authCookies as Parameters<typeof browser.setCookies>[2],
518
- );
519
- } catch {
520
- /* ignore */
521
- }
522
- }
523
- if (authLocalStorage && Object.keys(authLocalStorage).length) {
524
- try {
525
- await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
526
- } catch {
527
- /* ignore */
528
- }
529
- }
530
- }
531
-
532
412
  // Cap on printed output so a command cannot flood a terminal or an agent's
533
413
  // context with a whole page. 0 disables truncation.
534
414
  export const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
package/src/cli.ts CHANGED
@@ -206,7 +206,9 @@ async function main(): Promise<void> {
206
206
  }
207
207
 
208
208
  if (userArgs[0] === '--version' || userArgs[0] === '-V') {
209
- const pkg = _require('../package.json') as { version: string };
209
+ // Resolved from the COMPILED location, `dist/src/cli.js` — `rootDir: "."`
210
+ // keeps the `src/` segment, so the manifest is two levels up, not one.
211
+ const pkg = _require('../../package.json') as { version: string };
210
212
  console.log(pkg.version);
211
213
  return;
212
214
  }