@monoes/monobrowse 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/src/__tests__/batch-eval-parsing.test.d.ts +2 -0
  2. package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/batch-eval-parsing.test.js +47 -0
  4. package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -0
  5. package/dist/src/__tests__/browser-launch.test.d.ts +2 -0
  6. package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/browser-launch.test.js +94 -0
  8. package/dist/src/__tests__/browser-launch.test.js.map +1 -0
  9. package/dist/src/__tests__/ref-cache.test.js +36 -0
  10. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  11. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  12. package/dist/src/browser/actions.d.ts +1 -1
  13. package/dist/src/browser/actions.d.ts.map +1 -1
  14. package/dist/src/browser/actions.js +19 -2
  15. package/dist/src/browser/actions.js.map +1 -1
  16. package/dist/src/browser/browser.d.ts +10 -0
  17. package/dist/src/browser/browser.d.ts.map +1 -1
  18. package/dist/src/browser/browser.js +158 -5
  19. package/dist/src/browser/browser.js.map +1 -1
  20. package/dist/src/browser/cdp.d.ts.map +1 -1
  21. package/dist/src/browser/cdp.js +1 -1
  22. package/dist/src/browser/cdp.js.map +1 -1
  23. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  24. package/dist/src/browser/dashboard/server.js +37 -2
  25. package/dist/src/browser/dashboard/server.js.map +1 -1
  26. package/dist/src/browser/dashboard/ui.html +1 -1
  27. package/dist/src/browser/find.js.map +1 -1
  28. package/dist/src/browser/network.d.ts +7 -0
  29. package/dist/src/browser/network.d.ts.map +1 -1
  30. package/dist/src/browser/network.js +10 -0
  31. package/dist/src/browser/network.js.map +1 -1
  32. package/dist/src/browser/pdf.js.map +1 -1
  33. package/dist/src/browser/profiler.js.map +1 -1
  34. package/dist/src/browser/record.d.ts +3 -0
  35. package/dist/src/browser/record.d.ts.map +1 -1
  36. package/dist/src/browser/record.js +37 -5
  37. package/dist/src/browser/record.js.map +1 -1
  38. package/dist/src/browser/ref-cache.d.ts +20 -0
  39. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  40. package/dist/src/browser/ref-cache.js +50 -0
  41. package/dist/src/browser/ref-cache.js.map +1 -1
  42. package/dist/src/browser/screenshot.js.map +1 -1
  43. package/dist/src/browser/session.d.ts.map +1 -1
  44. package/dist/src/browser/session.js +12 -7
  45. package/dist/src/browser/session.js.map +1 -1
  46. package/dist/src/browser/snapshot.js.map +1 -1
  47. package/dist/src/browser/trace.js.map +1 -1
  48. package/dist/src/browser/types.d.ts +5 -0
  49. package/dist/src/browser/types.d.ts.map +1 -1
  50. package/dist/src/browser/types.js.map +1 -1
  51. package/dist/src/cli/action.js.map +1 -1
  52. package/dist/src/cli/commands.d.ts +22 -0
  53. package/dist/src/cli/commands.d.ts.map +1 -1
  54. package/dist/src/cli/commands.js +246 -27
  55. package/dist/src/cli/commands.js.map +1 -1
  56. package/dist/src/cli/output.d.ts.map +1 -1
  57. package/dist/src/cli.js.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +2 -2
  60. package/src/__tests__/batch-eval-parsing.test.ts +52 -0
  61. package/src/__tests__/browser-launch.test.ts +102 -0
  62. package/src/__tests__/ref-cache.test.ts +41 -0
  63. package/src/browser/actions.ts +22 -2
  64. package/src/browser/browser.ts +163 -4
  65. package/src/browser/cdp.ts +1 -1
  66. package/src/browser/dashboard/server.ts +37 -2
  67. package/src/browser/dashboard/ui.html +1 -1
  68. package/src/browser/network.ts +11 -0
  69. package/src/browser/record.ts +43 -6
  70. package/src/browser/ref-cache.ts +51 -0
  71. package/src/browser/session.ts +13 -7
  72. package/src/browser/types.ts +5 -0
  73. package/src/cli/commands.ts +239 -27
@@ -16,6 +16,7 @@ import type { ElementRef } from './types.js';
16
16
 
17
17
  const CACHE_DIR = join(process.cwd(), '.monomind', 'monobrowse');
18
18
  const CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
19
+ const PORT_FILE = join(CACHE_DIR, 'active-port.json');
19
20
 
20
21
  /** Snapshot older than this is flagged as possibly stale (page may have changed). */
21
22
  export const REF_CACHE_STALE_MS = 30_000;
@@ -88,3 +89,53 @@ export async function clearRefCache(): Promise<void> {
88
89
  // Nothing to clear, or not writable — non-fatal.
89
90
  }
90
91
  }
92
+
93
+ /**
94
+ * Persist the "active" CDP port so a later CLI invocation (each command is a
95
+ * fresh process — see module header) can find the browser a prior `open
96
+ * --port N` attached to, instead of every subsequent command silently
97
+ * falling back to the hardcoded default port and launching/attaching to a
98
+ * second, unrelated Chrome instance.
99
+ */
100
+ export async function saveActivePort(port: number, opts?: { launched?: boolean }): Promise<void> {
101
+ try {
102
+ await mkdir(CACHE_DIR, { recursive: true });
103
+ // `launched` records provenance: true = monobrowse spawned this Chrome
104
+ // (safe to Browser.close later); false = attached to a browser someone
105
+ // else owns (must never be killed). Absent (old files) reads as launched
106
+ // — matches pre-flag behavior where every persisted port came from open.
107
+ await writeFile(PORT_FILE, JSON.stringify({ port, launched: opts?.launched !== false, savedAt: Date.now() }));
108
+ } catch {
109
+ // Best-effort — persistence failure just means the next process falls
110
+ // back to the hardcoded default port, matching prior behavior.
111
+ }
112
+ }
113
+
114
+ /** Forget the persisted active port (session closed) so later invocations
115
+ * fall back to the default instead of chasing a dead endpoint. */
116
+ export async function clearActivePort(): Promise<void> {
117
+ try {
118
+ await rm(PORT_FILE, { force: true });
119
+ } catch {
120
+ // Best-effort — a stale port file only costs one failed probe later.
121
+ }
122
+ }
123
+
124
+ /** Load the persisted active port, or null if none was ever saved / it's unreadable. */
125
+ export async function loadActivePort(): Promise<number | null> {
126
+ return (await loadActivePortInfo())?.port ?? null;
127
+ }
128
+
129
+ /** Load the persisted active port with its provenance flag. */
130
+ export async function loadActivePortInfo(): Promise<{ port: number; launched: boolean } | null> {
131
+ try {
132
+ const raw = await readFile(PORT_FILE, 'utf8');
133
+ const data = JSON.parse(raw) as { port?: unknown; launched?: unknown };
134
+ if (typeof data.port === 'number' && Number.isInteger(data.port) && data.port >= 1024 && data.port <= 65535) {
135
+ return { port: data.port, launched: data.launched !== false };
136
+ }
137
+ return null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
@@ -3,7 +3,13 @@ import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import type { SessionState, CdpCookie } from './types.js';
5
5
  import type { CdpClient } from './cdp.js';
6
- import { getCookies, setCookies, getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from './network.js';
6
+ import { getCookiesForUrls, setCookies, getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from './network.js';
7
+
8
+ // Session state files contain cookies/localStorage — treat as secrets:
9
+ // owner-only read/write on the file (0600) and owner-only access on the
10
+ // containing directory (0700) so other local users can't read session tokens.
11
+ const SESSION_FILE_MODE = 0o600;
12
+ const SESSION_DIR_MODE = 0o700;
7
13
 
8
14
  const SESSION_DIR = join(homedir(), '.monomind', 'browser-sessions');
9
15
 
@@ -31,15 +37,15 @@ export async function saveSession(
31
37
  title: string
32
38
  ): Promise<string> {
33
39
  validateSessionName(name);
34
- await mkdir(SESSION_DIR, { recursive: true });
40
+ await mkdir(SESSION_DIR, { recursive: true, mode: SESSION_DIR_MODE });
35
41
 
36
- const cookies = await getCookies(client, sessionId);
42
+ const cookies = await getCookiesForUrls(client, sessionId, [url]);
37
43
  const localStorage = await getLocalStorage(client, sessionId);
38
44
  const sessionStorage = await getSessionStorage(client, sessionId);
39
45
 
40
46
  const state: SessionState = { targetId, sessionId, url, title, cookies, localStorage, sessionStorage };
41
47
  const filePath = join(SESSION_DIR, `${name}.json`);
42
- await writeFile(filePath, JSON.stringify(state, null, 2));
48
+ await writeFile(filePath, JSON.stringify(state, null, 2), { mode: SESSION_FILE_MODE });
43
49
  return filePath;
44
50
  }
45
51
 
@@ -79,12 +85,12 @@ export async function saveStateFile(
79
85
  title: string
80
86
  ): Promise<void> {
81
87
  validateFilePath(filePath);
82
- await mkdir(dirname(filePath), { recursive: true });
83
- const cookies = await getCookies(client, sessionId);
88
+ await mkdir(dirname(filePath), { recursive: true, mode: SESSION_DIR_MODE });
89
+ const cookies = await getCookiesForUrls(client, sessionId, [url]);
84
90
  const localStorage = await getLocalStorage(client, sessionId);
85
91
  const sessionStorage = await getSessionStorage(client, sessionId);
86
92
  const state: SessionState = { targetId, sessionId, url, title, cookies, localStorage, sessionStorage };
87
- await writeFile(filePath, JSON.stringify(state, null, 2));
93
+ await writeFile(filePath, JSON.stringify(state, null, 2), { mode: SESSION_FILE_MODE });
88
94
  }
89
95
 
90
96
  export async function loadStateFile(
@@ -1,5 +1,10 @@
1
1
  export interface BrowserConfig {
2
2
  port?: number;
3
+ /** Fail fast if `port` is occupied by anything other than an attachable
4
+ * Chrome, instead of scanning forward to the next free port. Default false
5
+ * (scan) — set true for callers that treat the occupied-port error as a
6
+ * signal rather than consuming launchBrowser's returned port. */
7
+ strictPort?: boolean;
3
8
  headless?: boolean;
4
9
  executablePath?: string;
5
10
  userDataDir?: string;
@@ -10,16 +10,42 @@ import { createPlatformCommand } from './platform.js';
10
10
  import type { CdpClient, ElementRef, NetworkRoute, FindAction } from '../index.js';
11
11
 
12
12
  // Runtime state (single session per CLI process)
13
+ const DEFAULT_PORT = 9222;
13
14
  let _client: CdpClient | null = null;
14
15
  let _sessionId = '';
15
16
  let _targetId = '';
16
- let _port = 9222;
17
+ let _port = DEFAULT_PORT;
17
18
  let _refs: Map<string, ElementRef> = new Map();
18
19
 
19
20
  async function getBrowser() {
20
21
  return import('../index.js');
21
22
  }
22
23
 
24
+ // Each CLI invocation is a fresh process, so this module's `_port` variable
25
+ // is always freshly initialized to DEFAULT_PORT — a `--port 9333` passed to
26
+ // an earlier `open` command has no effect on this process's own default.
27
+ // Resolve the persisted "active port" (written by `open --port N`, read via
28
+ // ref-cache.ts's saveActivePort/loadActivePort) so subsequent commands
29
+ // attach to the browser the user actually opened instead of silently
30
+ // launching/attaching to a second, unrelated Chrome instance on 9222.
31
+ async function resolveDefaultPort(browser: Awaited<ReturnType<typeof getBrowser>>): Promise<number> {
32
+ const persisted = await browser.loadActivePortInfo();
33
+ if (!persisted) return DEFAULT_PORT;
34
+ if (!persisted.launched) {
35
+ // connect-origin port: the browser belongs to someone else. If it died,
36
+ // relaunching our own headless Chrome on that port would squat the
37
+ // user's debug port and silently swap which browser commands act on —
38
+ // fail loudly instead.
39
+ try {
40
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(1500) });
41
+ } catch {
42
+ await browser.clearActivePort();
43
+ throw new Error(`Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`);
44
+ }
45
+ }
46
+ return persisted.port;
47
+ }
48
+
23
49
  async function ensureConnected(port: number, targetId?: string) {
24
50
  const browser = await getBrowser();
25
51
  if (!_client || !_client.isConnected()) {
@@ -30,13 +56,14 @@ async function ensureConnected(port: number, targetId?: string) {
30
56
  browser.teardownConsoleCapture(_sessionId);
31
57
  _client.close();
32
58
  }
33
- _port = await browser.launchBrowser({ port, headless: true });
59
+ const effectivePort = port === DEFAULT_PORT ? await resolveDefaultPort(browser) : port;
60
+ _port = await browser.launchBrowser({ port: effectivePort, headless: true });
34
61
  const conn = await browser.connectToTarget(_port, targetId);
35
62
  _client = conn.client;
36
63
  _sessionId = conn.sessionId;
37
64
  _targetId = conn.target.id;
38
65
  _refs = new Map();
39
- await hydrateRefsFromCache(browser, _targetId);
66
+ await hydrateRefsFromCache(browser, _targetId, conn.target.url);
40
67
  }
41
68
  return { client: _client!, sessionId: _sessionId, targetId: _targetId };
42
69
  }
@@ -46,9 +73,29 @@ async function ensureConnected(port: number, targetId?: string) {
46
73
  // command runs. Rehydrate it from the on-disk ref cache (written by
47
74
  // captureSnapshot call sites below) so refs resolved by a previous process
48
75
  // remain usable. Falls back silently to an empty Map if no cache exists.
49
- async function hydrateRefsFromCache(browser: Awaited<ReturnType<typeof getBrowser>>, targetId: string): Promise<void> {
76
+ //
77
+ // The cached `url` field (captured at snapshot time) is compared against the
78
+ // browser's CURRENT url (`currentUrl`, from the just-fetched target info).
79
+ // A stale `backendDOMNodeId` can still successfully resolve via
80
+ // DOM.getBoxModel even after a same-tab SPA navigation or DOM mutation
81
+ // changed what's actually at those coordinates — so a mismatch here means
82
+ // EVERY ref in the cache is potentially pointing at the wrong element. We
83
+ // hard-invalidate (skip hydration entirely, so any @eN lookup fails loudly
84
+ // via resolveRef's "not found" error) rather than the weaker 30s time-based
85
+ // staleness check below, which only warns.
86
+ async function hydrateRefsFromCache(
87
+ browser: Awaited<ReturnType<typeof getBrowser>>,
88
+ targetId: string,
89
+ currentUrl: string
90
+ ): Promise<void> {
50
91
  const cached = await browser.loadRefCache(targetId);
51
92
  if (!cached) return;
93
+ if (currentUrl && cached.url && currentUrl !== cached.url) {
94
+ output.printError(
95
+ `Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`
96
+ );
97
+ return; // leave _refs empty — do not attempt to resolve refs against a different page
98
+ }
52
99
  _refs = cached.refs;
53
100
  if (cached.stale) {
54
101
  output.printWarning(
@@ -145,11 +192,14 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
145
192
  const authCookies = await browser.getCookies(_client, _sessionId).catch(() => [] as unknown[]);
146
193
  const authLocalStorage = await browser.getLocalStorage(_client, _sessionId).catch(() => ({}) as Record<string, string>);
147
194
 
148
- // Close headed session
195
+ // Close headed session — actually terminate the underlying Chrome process
196
+ // (Browser.close CDP command, PID-kill fallback), not just our CDP client
197
+ // connection, so the visible authenticated window doesn't linger forever.
149
198
  browser.teardownRouteInterception(_sessionId);
150
199
  browser.stopRequestCapture(_sessionId);
151
200
  browser.teardownDialogHandling(_sessionId);
152
201
  browser.teardownConsoleCapture(_sessionId);
202
+ await browser.closeBrowser(_client, headedPort);
153
203
  _client.close();
154
204
  _client = null;
155
205
  _sessionId = '';
@@ -232,11 +282,18 @@ const openCommand: Command = {
232
282
  }
233
283
 
234
284
  _port = await browser.launchBrowser({ port, headless: !forceHeaded });
285
+ // Persist the active port so subsequent CLI invocations (each a fresh
286
+ // process) default to attaching here instead of hardcoded 9222.
287
+ await browser.saveActivePort(_port);
235
288
  const conn = await browser.connectToTarget(_port);
236
289
  _client = conn.client;
237
290
  _sessionId = conn.sessionId;
238
291
  _targetId = conn.target.id;
239
292
  _refs = new Map();
293
+ // A snapshot taken before this navigation is no longer valid for the new
294
+ // page — drop the persisted ref cache so a stale process's weak
295
+ // time-based check can't resurrect it before the next explicit snapshot.
296
+ await browser.clearRefCache();
240
297
 
241
298
  if (ctx.flags.state && ctx.flags.session) {
242
299
  output.printWarning('Both --state and --session provided; --state takes precedence');
@@ -298,11 +355,8 @@ const snapshotCommand: Command = {
298
355
  await browser.saveRefCache(_targetId, result.url, _refs);
299
356
 
300
357
  const applyOutputLimits = (text: string): string => {
301
- let out = text;
302
358
  const maxOutput = ctx.flags['max-output'] as number | undefined;
303
- if (maxOutput && out.length > maxOutput) {
304
- out = out.slice(0, maxOutput) + `\n[... truncated at ${maxOutput} chars]`;
305
- }
359
+ let out = maxOutput ? truncateForOutput(text, maxOutput) : text;
306
360
  if (ctx.flags['content-boundaries']) {
307
361
  const nonce = Math.random().toString(36).slice(2, 10);
308
362
  out = `MONOMIND_PAGE_CONTENT nonce=${nonce} origin=${result.url}\n${out}\nEND_MONOMIND_PAGE_CONTENT nonce=${nonce}`;
@@ -500,16 +554,29 @@ const waitCommand: Command = {
500
554
  const rawTimeout = Math.min((ctx.flags.timeout as number) ?? 30000, MAX_DOWNLOAD_TIMEOUT);
501
555
  const finalPath = await new Promise<string>((resolve, reject) => {
502
556
  let guid = '';
557
+ let settled = false;
503
558
  // C2: capture off() functions to avoid listener leaks
504
559
  const offBegin = client.on('Browser.downloadWillBegin', (params: Record<string, unknown>) => { guid = params.guid as string; });
505
560
  let offProgress: (() => void) | undefined;
506
561
  // cleanup defined before setTimeout so the timeout callback can call it
507
562
  let tid: ReturnType<typeof setTimeout>;
508
- const cleanup = () => { clearTimeout(tid); offBegin?.(); offProgress?.(); };
509
- tid = setTimeout(() => { cleanup(); reject(new Error('Download timed out')); }, rawTimeout);
563
+ let pollTid: ReturnType<typeof setInterval> | undefined;
564
+ const cleanup = () => { clearTimeout(tid); clearInterval(pollTid); offBegin?.(); offProgress?.(); };
565
+ const finish = (path: string) => {
566
+ if (settled) return;
567
+ settled = true;
568
+ cleanup();
569
+ resolve(path);
570
+ };
571
+ const fail = (err: Error) => {
572
+ if (settled) return;
573
+ settled = true;
574
+ cleanup();
575
+ reject(err);
576
+ };
577
+ tid = setTimeout(() => fail(new Error('Download timed out')), rawTimeout);
510
578
  offProgress = client.on('Browser.downloadProgress', async (params: Record<string, unknown>) => {
511
579
  if (params.guid === guid && params.state === 'completed') {
512
- cleanup();
513
580
  const { readdir, rename, rmdir } = await import('fs/promises');
514
581
  const files = await readdir(downloadDir);
515
582
  if (files.length > 0) {
@@ -517,16 +584,40 @@ const waitCommand: Command = {
517
584
  await mkdir(dirname(savePath), { recursive: true });
518
585
  await rename(src, savePath);
519
586
  await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
520
- resolve(savePath);
587
+ finish(savePath);
521
588
  } else {
522
589
  await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
523
- reject(new Error('Download completed but no file found'));
590
+ fail(new Error('Download completed but no file found'));
524
591
  }
525
592
  } else if (params.guid === guid && params.state === 'canceled') {
526
- cleanup();
527
- reject(new Error('Download was canceled'));
593
+ fail(new Error('Download was canceled'));
528
594
  }
529
595
  });
596
+
597
+ // Fallback for the empty-guid race: this process's listener attaches
598
+ // AFTER a separate `click` process may have already started (and even
599
+ // finished) the download — so downloadWillBegin/downloadProgress for it
600
+ // were never observed here. While guid is still empty, poll the expected
601
+ // output path directly: if it already exists with a stable, non-zero
602
+ // size across two consecutive checks, treat the download as complete.
603
+ let lastSize = -1;
604
+ let stableReads = 0;
605
+ pollTid = setInterval(async () => {
606
+ if (settled || guid) return; // a real CDP event has taken over
607
+ try {
608
+ const { stat } = await import('fs/promises');
609
+ const st = await stat(savePath);
610
+ if (st.isFile() && st.size > 0 && st.size === lastSize) {
611
+ stableReads++;
612
+ if (stableReads >= 2) finish(savePath);
613
+ } else {
614
+ stableReads = 0;
615
+ }
616
+ lastSize = st.size;
617
+ } catch {
618
+ // savePath not present yet — keep polling until timeout
619
+ }
620
+ }, 300);
530
621
  });
531
622
  output.printSuccess(`Download saved: ${finalPath}`);
532
623
  return { success: true, data: { path: finalPath } };
@@ -802,6 +893,10 @@ const navigateCommand: Command = {
802
893
  throw new Error(`Unknown direction: ${direction}. Use: back|forward|reload`);
803
894
  }
804
895
 
896
+ // Refs captured before this navigation may now point at different content.
897
+ _refs = new Map();
898
+ await browser.clearRefCache();
899
+
805
900
  output.printSuccess(`Navigated: ${direction}`);
806
901
  return { success: true };
807
902
  },
@@ -1107,12 +1202,26 @@ const networkCommand: Command = {
1107
1202
  },
1108
1203
  };
1109
1204
 
1205
+ // Default output cap for eval when --max-output isn't passed explicitly —
1206
+ // print(String(result)) had NO limit at all (unlike snapshot's --max-output),
1207
+ // so e.g. `eval "document.documentElement.outerHTML"` could dump megabytes
1208
+ // straight into an agent's context. An explicit --max-output still overrides
1209
+ // this default.
1210
+ const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
1211
+
1212
+ function truncateForOutput(text: string, maxOutput: number): string {
1213
+ if (!(maxOutput > 0) || text.length <= maxOutput) return text;
1214
+ return text.slice(0, maxOutput) + `\n[... truncated at ${maxOutput} chars]`;
1215
+ }
1216
+
1110
1217
  const evalCommand: Command = {
1111
1218
  name: 'eval',
1112
1219
  description: 'Evaluate JavaScript in page context. Usage: monomind browse eval "document.title"',
1113
1220
  options: [
1114
1221
  { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
1115
1222
  { name: 'stdin', type: 'boolean', description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)', default: false },
1223
+ { name: 'max-output', type: 'number', description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)` },
1224
+ { name: 'timeout', type: 'number', description: 'Max ms to wait for evaluation to settle (default 30000)' },
1116
1225
  ],
1117
1226
  action: async (ctx: CommandContext): Promise<CommandResult> => {
1118
1227
  const { client, sessionId } = await ensureConnected(_port);
@@ -1126,12 +1235,14 @@ const evalCommand: Command = {
1126
1235
  }
1127
1236
  if (!expr) throw new Error('Usage: monomind browse eval "<expression>" (or pipe with --stdin)');
1128
1237
 
1129
- const result = await browser.evaluateJs(client, sessionId, expr);
1238
+ const timeoutMs = ctx.flags.timeout as number | undefined;
1239
+ const result = await browser.evaluateJs(client, sessionId, expr, timeoutMs);
1130
1240
 
1241
+ const maxOutput = (ctx.flags['max-output'] as number | undefined) ?? DEFAULT_EVAL_MAX_OUTPUT;
1131
1242
  if (ctx.flags.json) {
1132
- print(JSON.stringify({ data: result }));
1243
+ print(truncateForOutput(JSON.stringify({ data: result }), maxOutput));
1133
1244
  } else {
1134
- print(String(result ?? ''));
1245
+ print(truncateForOutput(String(result ?? ''), maxOutput));
1135
1246
  }
1136
1247
 
1137
1248
  return { success: true, data: { result } };
@@ -1165,9 +1276,54 @@ const closeCommand: Command = {
1165
1276
  _sessionId = '';
1166
1277
  _targetId = '';
1167
1278
  _refs = new Map();
1279
+ // Session is gone — forget the persisted port and stale refs so later
1280
+ // invocations don't chase a dead endpoint (or the wrong elements).
1281
+ await browser.clearActivePort();
1282
+ await browser.clearRefCache();
1168
1283
  output.printSuccess('Browser session closed');
1169
1284
  } else {
1170
- output.printInfo('No active browser session');
1285
+ // Each CLI invocation is a fresh process, so `close` almost always
1286
+ // lands here. The persisted port file is the real session handle:
1287
+ // if monobrowse LAUNCHED that browser (open), gracefully Browser.close
1288
+ // it — otherwise every open→close cycle leaks a headless Chrome. If we
1289
+ // merely ATTACHED to it (connect, launched:false), never kill it: it's
1290
+ // the user's own browser. Either way, forget the port and refs.
1291
+ const browser = await getBrowser();
1292
+ const persisted = await browser.loadActivePortInfo();
1293
+ if (persisted?.launched) {
1294
+ try {
1295
+ const conn = await browser.connectToTarget(persisted.port);
1296
+ try {
1297
+ await browser.closeBrowser(conn.client, persisted.port);
1298
+ } finally {
1299
+ // Always drop our websocket — a hung Browser.close must not keep
1300
+ // this CLI process's event loop alive.
1301
+ try { conn.client.close(); } catch { /* already gone */ }
1302
+ }
1303
+ // closeBrowser has no PID fallback in a fresh process (launchedPids
1304
+ // is per-process) — re-probe so we report what actually happened.
1305
+ // Poll briefly: Browser.close is acknowledged before the process
1306
+ // actually exits, so a single immediate probe false-alarms.
1307
+ let stillUp = true;
1308
+ const probeDeadline = Date.now() + 3000;
1309
+ while (stillUp && Date.now() < probeDeadline) {
1310
+ try {
1311
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(800) });
1312
+ await new Promise(r => setTimeout(r, 300));
1313
+ } catch { stillUp = false; }
1314
+ }
1315
+ if (stillUp) output.printWarning(`Browser on port ${persisted.port} did not exit — kill it manually if needed`);
1316
+ else output.printSuccess(`Closed browser on port ${persisted.port}`);
1317
+ } catch {
1318
+ output.printInfo(`No browser answering on port ${persisted.port} — nothing to close`);
1319
+ }
1320
+ } else {
1321
+ output.printInfo(persisted
1322
+ ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
1323
+ : 'No active browser session');
1324
+ }
1325
+ await browser.clearActivePort();
1326
+ await browser.clearRefCache();
1171
1327
  }
1172
1328
  return { success: true };
1173
1329
  },
@@ -2297,6 +2453,10 @@ const pushstateCommand: Command = {
2297
2453
  const url = ctx.args[0] as string;
2298
2454
  if (!url) throw new Error('Usage: monomind browse pushstate <url>');
2299
2455
  await browser.pushState(client, sessionId, url);
2456
+ // SPA navigation changes what's on the page without a full page load —
2457
+ // refs captured before this pushState call may now resolve to different content.
2458
+ _refs = new Map();
2459
+ await browser.clearRefCache();
2300
2460
  output.printSuccess(`pushState: ${url}`);
2301
2461
  return { success: true };
2302
2462
  },
@@ -2322,6 +2482,54 @@ function tokenizeBatchCommand(input: string): string[] {
2322
2482
  return tokens;
2323
2483
  }
2324
2484
 
2485
+ /**
2486
+ * Splits one line of a `batch` command string into a subcommand name, its
2487
+ * args, and any recognized leading flags.
2488
+ *
2489
+ * `eval` is special-cased: its sole argument is a raw JS expression, which
2490
+ * legitimately contains its own string-literal quotes (e.g.
2491
+ * `eval document.querySelector('a')`). tokenizeBatchCommand's shell-style
2492
+ * word-splitting treats `'...'`/`"..."` as grouping delimiters and discards
2493
+ * them — correct for a value like `fill @e1 "some text"`, but for `eval` it
2494
+ * silently deletes the expression's own quotes, turning a string literal
2495
+ * into a bare (undefined) identifier reference and producing a
2496
+ * ReferenceError instead of evaluating the intended expression. For `eval`,
2497
+ * only its own known --flags are consumed from the front; everything after
2498
+ * them is taken verbatim as one argument, untouched by tokenization.
2499
+ *
2500
+ * Exported for direct unit testing — not part of the CLI's public API.
2501
+ */
2502
+ export function parseBatchCommandLine(cmdStr: string): { subName: string; subArgs: string[]; flags: Record<string, unknown> } {
2503
+ const trimmed = cmdStr.trim();
2504
+ const evalMatch = trimmed.match(/^eval\b\s*/);
2505
+ if (!evalMatch) {
2506
+ const parts = tokenizeBatchCommand(trimmed);
2507
+ return { subName: parts[0], subArgs: parts.slice(1), flags: {} };
2508
+ }
2509
+
2510
+ let rest = trimmed.slice(evalMatch[0].length);
2511
+ const flags: Record<string, unknown> = {};
2512
+ let consumedAnother = true;
2513
+ while (consumedAnother) {
2514
+ consumedAnother = false;
2515
+ const withValue = rest.match(/^--(max-output|timeout)\s+(-?\d+)\s*/);
2516
+ if (withValue) {
2517
+ flags[withValue[1]] = Number(withValue[2]);
2518
+ rest = rest.slice(withValue[0].length);
2519
+ consumedAnother = true;
2520
+ continue;
2521
+ }
2522
+ const boolFlag = rest.match(/^--(json|stdin)\b\s*/);
2523
+ if (boolFlag) {
2524
+ flags[boolFlag[1]] = true;
2525
+ rest = rest.slice(boolFlag[0].length);
2526
+ consumedAnother = true;
2527
+ continue;
2528
+ }
2529
+ }
2530
+ return { subName: 'eval', subArgs: [rest], flags };
2531
+ }
2532
+
2325
2533
  const batchCommand: Command = {
2326
2534
  name: 'batch',
2327
2535
  description: 'Execute multiple commands. Usage: monomind browse batch "open url" "snapshot -i" "click @e1"',
@@ -2335,9 +2543,7 @@ const batchCommand: Command = {
2335
2543
 
2336
2544
  const results: Array<{ command: string; success: boolean; error?: string }> = [];
2337
2545
  for (const cmdStr of commands) {
2338
- const parts = tokenizeBatchCommand(cmdStr);
2339
- const subName = parts[0];
2340
- const subArgs = parts.slice(1);
2546
+ const { subName, subArgs, flags: preParsedFlags } = parseBatchCommandLine(cmdStr);
2341
2547
 
2342
2548
  const subCmd = browseCommand.subcommands?.find((s) => s.name === subName);
2343
2549
  if (!subCmd?.action) {
@@ -2348,7 +2554,7 @@ const batchCommand: Command = {
2348
2554
  }
2349
2555
 
2350
2556
  try {
2351
- const parsedFlags: CommandContext['flags'] = { _: [] };
2557
+ const parsedFlags: CommandContext['flags'] = { _: [], ...preParsedFlags };
2352
2558
  const consumedIndices = new Set<number>();
2353
2559
  // Parse --flags from subArgs, tracking which indices are flag names/values
2354
2560
  for (let i = 0; i < subArgs.length; i++) {
@@ -2452,7 +2658,7 @@ const removeinitscriptCommand: Command = {
2452
2658
 
2453
2659
  const connectCommand: Command = {
2454
2660
  name: 'connect',
2455
- description: 'Connect to existing Chrome instance. Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
2661
+ description: 'Connect to existing Chrome instance; later commands reuse this session (note: `open` without --port still launches on its own default). Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
2456
2662
  options: [
2457
2663
  { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
2458
2664
  { name: 'target', type: 'string', description: 'Target ID to attach to' },
@@ -2504,6 +2710,12 @@ const connectCommand: Command = {
2504
2710
  _targetId = conn.target.id;
2505
2711
  _port = port;
2506
2712
  _refs = new Map();
2713
+ // Persist the port like `open` does — without this, the NEXT CLI process
2714
+ // (each command is a fresh process) resolves the hardcoded default and
2715
+ // tries to launch its own Chrome on 9222 instead of reusing this session.
2716
+ // launched:false marks this browser as someone else's — close must never
2717
+ // kill it, and a dead endpoint must not be silently relaunched.
2718
+ await browser.saveActivePort(port, { launched: false });
2507
2719
  const url = await browser.getCurrentUrl(_client, _sessionId);
2508
2720
  const title = await browser.getCurrentTitle(_client, _sessionId);
2509
2721
  output.printSuccess(`Connected: ${title} (${url})`);
@@ -2542,7 +2754,7 @@ const recordCommand: Command = {
2542
2754
  case 'restart': {
2543
2755
  const prevStatus = browser.getRecordingStatus(sessionId);
2544
2756
  let prevPath: string | undefined;
2545
- if (prevStatus.recording) {
2757
+ if (prevStatus.recording || prevStatus.autoStopped) {
2546
2758
  prevPath = await browser.stopRecording(client, sessionId, ctx.args[1] as string);
2547
2759
  output.printInfo(`Previous recording saved: ${prevPath}`);
2548
2760
  }
@@ -2556,7 +2768,7 @@ const recordCommand: Command = {
2556
2768
  case 'status': {
2557
2769
  const status = browser.getRecordingStatus(sessionId);
2558
2770
  if (ctx.flags.json) print(JSON.stringify({ data: status }));
2559
- else print(`Recording: ${status.recording} | Frames: ${status.frames}`);
2771
+ else print(`Recording: ${status.recording} | Frames: ${status.frames}${status.autoStopped ? ' (auto-stopped: buffer limit reached — run "record stop" to save)' : ''}`);
2560
2772
  return { success: true, data: status };
2561
2773
  }
2562
2774
  default: