@monoes/monobrowse 1.0.19 → 1.0.21

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 (61) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.d.ts +15 -0
  2. package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/browse-concurrency.test.js +198 -0
  4. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
  5. package/dist/src/__tests__/close-browser.test.js +2 -2
  6. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  7. package/dist/src/__tests__/frame-command.test.js +12 -2
  8. package/dist/src/__tests__/frame-command.test.js.map +1 -1
  9. package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
  10. package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
  11. package/dist/src/__tests__/ref-cache.test.js +96 -58
  12. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  13. package/dist/src/__tests__/report-exit-code.test.js +1 -1
  14. package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
  15. package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
  16. package/dist/src/__tests__/session-discovery.test.js +218 -0
  17. package/dist/src/__tests__/session-discovery.test.js.map +1 -0
  18. package/dist/src/browser/browser.d.ts +2 -1
  19. package/dist/src/browser/browser.d.ts.map +1 -1
  20. package/dist/src/browser/browser.js +17 -9
  21. package/dist/src/browser/browser.js.map +1 -1
  22. package/dist/src/browser/ref-cache.d.ts +50 -22
  23. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  24. package/dist/src/browser/ref-cache.js +122 -57
  25. package/dist/src/browser/ref-cache.js.map +1 -1
  26. package/dist/src/cli/commands-context.js +1 -1
  27. package/dist/src/cli/commands-context.js.map +1 -1
  28. package/dist/src/cli/commands-navigate.js +2 -2
  29. package/dist/src/cli/commands-navigate.js.map +1 -1
  30. package/dist/src/cli/commands-page.js +2 -2
  31. package/dist/src/cli/commands-page.js.map +1 -1
  32. package/dist/src/cli/commands-report.js +2 -2
  33. package/dist/src/cli/commands-report.js.map +1 -1
  34. package/dist/src/cli/commands-session.d.ts.map +1 -1
  35. package/dist/src/cli/commands-session.js +70 -56
  36. package/dist/src/cli/commands-session.js.map +1 -1
  37. package/dist/src/cli/commands.d.ts.map +1 -1
  38. package/dist/src/cli/commands.js +102 -66
  39. package/dist/src/cli/commands.js.map +1 -1
  40. package/dist/src/cli/session.d.ts +66 -2
  41. package/dist/src/cli/session.d.ts.map +1 -1
  42. package/dist/src/cli/session.js +190 -33
  43. package/dist/src/cli/session.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/browse-concurrency.test.ts +233 -0
  47. package/src/__tests__/close-browser.test.ts +2 -2
  48. package/src/__tests__/frame-command.test.ts +12 -2
  49. package/src/__tests__/reap-idle-browser.test.ts +15 -14
  50. package/src/__tests__/ref-cache.test.ts +112 -59
  51. package/src/__tests__/report-exit-code.test.ts +1 -1
  52. package/src/__tests__/session-discovery.test.ts +259 -0
  53. package/src/browser/browser.ts +23 -9
  54. package/src/browser/ref-cache.ts +144 -72
  55. package/src/cli/commands-context.ts +1 -1
  56. package/src/cli/commands-navigate.ts +2 -2
  57. package/src/cli/commands-page.ts +2 -2
  58. package/src/cli/commands-report.ts +2 -2
  59. package/src/cli/commands-session.ts +74 -55
  60. package/src/cli/commands.ts +106 -66
  61. package/src/cli/session.ts +208 -33
@@ -1,5 +1,12 @@
1
1
  /**
2
- * AX-tree ref cache persistence.
2
+ * Per-session CLI state: the browse session index, and the AX-tree ref cache
3
+ * each session owns.
4
+ *
5
+ * Both are keyed by CDP port and scoped to the working directory: one file
6
+ * per browse session under `sessions/`, one ref cache per session beside it.
7
+ * A single shared `active-port.json` / `ax-snapshot.json` pair used to stand
8
+ * for "the" session, which is why two concurrent `open` invocations in one
9
+ * directory overwrote each other's handle (#318).
3
10
  *
4
11
  * `snapshot` and `find` are separate CLI invocations — each starts a fresh
5
12
  * node process, so the in-memory ElementRef Map built by captureSnapshot()
@@ -10,13 +17,19 @@
10
17
  * The full AX-tree text/dump is intentionally NOT persisted — only the
11
18
  * per-ref index, to keep the file small.
12
19
  */
13
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
20
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
14
21
  import { join } from 'node:path';
15
22
  import type { ElementRef } from './types.js';
16
23
 
17
24
  const CACHE_DIR = join(process.cwd(), '.monomind', 'monobrowse');
18
- const CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
19
- const PORT_FILE = join(CACHE_DIR, 'active-port.json');
25
+ const SESSIONS_DIR = join(CACHE_DIR, 'sessions');
26
+ const refCacheFile = (port: number) => join(CACHE_DIR, `ax-snapshot-${port}.json`);
27
+ const sessionFile = (port: number) => join(SESSIONS_DIR, `${port}.json`);
28
+ // The pre-#318 layout: one session per directory, plus its one ref cache.
29
+ // Still read (see loadLegacySessionRecord) so a session opened by an older
30
+ // CLI is adopted rather than orphaned when monobrowse is upgraded under it.
31
+ const LEGACY_SESSION_FILE = join(CACHE_DIR, 'active-port.json');
32
+ const LEGACY_REF_CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
20
33
 
21
34
  /** Snapshot older than this is flagged as possibly stale (page may have changed). */
22
35
  export const REF_CACHE_STALE_MS = 30_000;
@@ -36,8 +49,11 @@ export interface RefCacheEntry {
36
49
  stale: boolean;
37
50
  }
38
51
 
39
- /** Persist the current ref index for a target so a later CLI process can read it back. */
52
+ /** Persist the current ref index for a target so a later CLI process can read
53
+ * it back. Scoped to the session's port so two sessions open in the same
54
+ * directory keep their own refs instead of clobbering one file. */
40
55
  export async function saveRefCache(
56
+ port: number,
41
57
  targetId: string,
42
58
  url: string,
43
59
  refs: Map<string, ElementRef>,
@@ -50,7 +66,7 @@ export async function saveRefCache(
50
66
  savedAt: Date.now(),
51
67
  refs: [...refs.values()],
52
68
  };
53
- await writeFile(CACHE_FILE, JSON.stringify(data));
69
+ await writeFile(refCacheFile(port), JSON.stringify(data));
54
70
  } catch {
55
71
  // Best-effort — persistence failure just means cross-process rehydration
56
72
  // won't work; in-process (same run) behavior is unaffected.
@@ -62,9 +78,9 @@ export async function saveRefCache(
62
78
  * different tab/target is ignored). Returns null if no usable cache exists —
63
79
  * callers fall back to an empty in-memory Map, matching prior behavior.
64
80
  */
65
- export async function loadRefCache(targetId: string): Promise<RefCacheEntry | null> {
81
+ export async function loadRefCache(port: number, targetId: string): Promise<RefCacheEntry | null> {
66
82
  try {
67
- const raw = await readFile(CACHE_FILE, 'utf8');
83
+ const raw = await readFile(refCacheFile(port), 'utf8');
68
84
  const data: RefCacheFile = JSON.parse(raw);
69
85
  if (!data || !Array.isArray(data.refs) || data.targetId !== targetId) return null;
70
86
 
@@ -82,104 +98,160 @@ export async function loadRefCache(targetId: string): Promise<RefCacheEntry | nu
82
98
  }
83
99
 
84
100
  /** Drop the persisted ref index — call whenever refs are invalidated (navigation, tab switch, close). */
85
- export async function clearRefCache(): Promise<void> {
101
+ export async function clearRefCache(port: number): Promise<void> {
86
102
  try {
87
- await rm(CACHE_FILE, { force: true });
103
+ await rm(refCacheFile(port), { force: true });
88
104
  } catch {
89
105
  // Nothing to clear, or not writable — non-fatal.
90
106
  }
91
107
  }
92
108
 
93
109
  /**
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.
110
+ * A browse session: one Chrome instance, identified by the CDP port it
111
+ * listens on, recorded so a LATER CLI invocation (each command is its own
112
+ * node process — see module header) can find it again.
99
113
  */
100
- export async function saveActivePort(
114
+ export interface SessionRecord {
115
+ port: number;
116
+ /** true = monobrowse launched this Chrome (safe to Browser.close later);
117
+ * false = we attached to a browser someone else owns (never kill it). */
118
+ launched: boolean;
119
+ pid?: number;
120
+ userDataDir?: string;
121
+ savedAt?: number;
122
+ }
123
+
124
+ /**
125
+ * Record a browse session under its own port, so concurrent sessions in the
126
+ * same working directory coexist instead of overwriting one shared handle.
127
+ *
128
+ * `pid`/`userDataDir` are persisted so a later, fresh CLI process can still
129
+ * find and kill the Chrome this one launched. Without them, closeBrowser()'s
130
+ * PID-kill fallback can never fire outside the launching process, since the
131
+ * in-memory launchedPids Map (browser.ts) is empty there.
132
+ */
133
+ export async function saveSessionRecord(
101
134
  port: number,
102
- opts?: { launched?: boolean; pid?: number; userDataDir?: string },
135
+ opts?: { launched?: boolean; pid?: number; userDataDir?: string; savedAt?: number },
103
136
  ): Promise<void> {
104
137
  try {
105
- await mkdir(CACHE_DIR, { recursive: true });
106
- // `launched` records provenance: true = monobrowse spawned this Chrome
107
- // (safe to Browser.close later); false = attached to a browser someone
108
- // else owns (must never be killed). Absent (old files) reads as launched
109
- // — matches pre-flag behavior where every persisted port came from open.
138
+ await mkdir(SESSIONS_DIR, { recursive: true });
139
+ // `launched` absent (older files) reads as launched — matches the
140
+ // pre-flag behavior where every persisted port came from `open`.
110
141
  //
111
- // `pid`/`userDataDir` are persisted so a LATER, fresh CLI process (each
112
- // command is its own node process — see module header) can still find
113
- // and kill the Chrome this one launched. Without this, closeBrowser()'s
114
- // PID-kill fallback can never fire outside the launching process, since
115
- // the in-memory launchedPids Map (browser.ts) is empty there.
142
+ // `savedAt` is passed only when re-recording a session that already
143
+ // existed (adopting a legacy record), so its real age — which is what
144
+ // the idle reaper and the newest-first ordering read — is preserved.
116
145
  await writeFile(
117
- PORT_FILE,
146
+ sessionFile(port),
118
147
  JSON.stringify({
119
148
  port,
120
149
  launched: opts?.launched !== false,
121
150
  pid: opts?.pid,
122
151
  userDataDir: opts?.userDataDir,
123
- savedAt: Date.now(),
152
+ savedAt: opts?.savedAt ?? Date.now(),
124
153
  }),
125
154
  );
126
155
  } catch {
127
- // Best-effort — persistence failure just means the next process falls
128
- // back to the hardcoded default port, matching prior behavior.
156
+ // Best-effort — persistence failure costs the next process the ability to
157
+ // find this session, not this command's own correctness.
129
158
  }
130
159
  }
131
160
 
132
- /** Forget the persisted active port (session closed) so later invocations
133
- * fall back to the default instead of chasing a dead endpoint. */
134
- export async function clearActivePort(): Promise<void> {
161
+ /** Forget one session (it was closed, or its browser is gone) so later
162
+ * invocations don't chase a dead endpoint. */
163
+ export async function removeSessionRecord(port: number): Promise<void> {
135
164
  try {
136
- await rm(PORT_FILE, { force: true });
165
+ await rm(sessionFile(port), { force: true });
137
166
  } catch {
138
- // Best-effort — a stale port file only costs one failed probe later.
167
+ // Best-effort — a stale record only costs one failed probe later.
139
168
  }
140
169
  }
141
170
 
142
- /** Load the persisted active port, or null if none was ever saved / it's unreadable. */
143
- export async function loadActivePort(): Promise<number | null> {
144
- return (await loadActivePortInfo())?.port ?? null;
171
+ /** The session recorded for `port`, or null if there is none / it's unreadable. */
172
+ export async function loadSessionRecord(port: number): Promise<SessionRecord | null> {
173
+ try {
174
+ return parseSessionRecord(await readFile(sessionFile(port), 'utf8'));
175
+ } catch {
176
+ return null;
177
+ }
145
178
  }
146
179
 
147
- /** Load the persisted active port with its provenance flag, PID, user-data-dir, and save timestamp. */
148
- export async function loadActivePortInfo(): Promise<{
149
- port: number;
150
- launched: boolean;
151
- pid?: number;
152
- userDataDir?: string;
153
- savedAt?: number;
154
- } | null> {
180
+ /**
181
+ * The session a pre-#318 CLI left behind in this directory, if any.
182
+ *
183
+ * That layout held exactly one session in `active-port.json`. Upgrading
184
+ * monobrowse while such a session was open would otherwise strand its
185
+ * Chrome: nothing reads that file any more, so the new CLI could neither
186
+ * find nor close it. It is read here as one more candidate session and then
187
+ * either adopted (rewritten as `sessions/<port>.json`) or dropped — see
188
+ * adoptLegacySession() in cli/session.ts. Nothing ever writes it again.
189
+ */
190
+ export async function loadLegacySessionRecord(): Promise<SessionRecord | null> {
155
191
  try {
156
- const raw = await readFile(PORT_FILE, 'utf8');
157
- const data = JSON.parse(raw) as {
158
- port?: unknown;
159
- launched?: unknown;
160
- pid?: unknown;
161
- userDataDir?: unknown;
162
- savedAt?: unknown;
163
- };
164
- if (
165
- typeof data.port === 'number' &&
166
- Number.isInteger(data.port) &&
167
- data.port >= 1024 &&
168
- data.port <= 65535
169
- ) {
170
- return {
171
- port: data.port,
172
- launched: data.launched !== false,
173
- pid:
174
- typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
175
- ? data.pid
176
- : undefined,
177
- userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
178
- savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
179
- };
180
- }
192
+ return parseSessionRecord(await readFile(LEGACY_SESSION_FILE, 'utf8'));
193
+ } catch {
181
194
  return null;
195
+ }
196
+ }
197
+
198
+ /** Drop the pre-#318 files, once their session has been adopted or found
199
+ * dead. The single ref cache goes with them: it belonged to that one
200
+ * session, and nothing reads it any more either. */
201
+ export async function removeLegacySessionRecord(): Promise<void> {
202
+ try {
203
+ await rm(LEGACY_SESSION_FILE, { force: true });
204
+ await rm(LEGACY_REF_CACHE_FILE, { force: true });
182
205
  } catch {
206
+ // Best-effort — a leftover file is inert; it is only ever read again.
207
+ }
208
+ }
209
+
210
+ /** Every recorded session in this working directory, newest first. */
211
+ export async function listSessionRecords(): Promise<SessionRecord[]> {
212
+ let entries: string[];
213
+ try {
214
+ entries = await readdir(SESSIONS_DIR);
215
+ } catch {
216
+ return [];
217
+ }
218
+ const records: SessionRecord[] = [];
219
+ for (const entry of entries) {
220
+ if (!entry.endsWith('.json')) continue;
221
+ try {
222
+ const record = parseSessionRecord(await readFile(join(SESSIONS_DIR, entry), 'utf8'));
223
+ if (record) records.push(record);
224
+ } catch {
225
+ // Unreadable file — skip it rather than failing the whole listing.
226
+ }
227
+ }
228
+ return records.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
229
+ }
230
+
231
+ function parseSessionRecord(raw: string): SessionRecord | null {
232
+ const data = JSON.parse(raw) as {
233
+ port?: unknown;
234
+ launched?: unknown;
235
+ pid?: unknown;
236
+ userDataDir?: unknown;
237
+ savedAt?: unknown;
238
+ };
239
+ if (
240
+ typeof data.port !== 'number' ||
241
+ !Number.isInteger(data.port) ||
242
+ data.port < 1024 ||
243
+ data.port > 65535
244
+ ) {
183
245
  return null;
184
246
  }
247
+ return {
248
+ port: data.port,
249
+ launched: data.launched !== false,
250
+ pid:
251
+ typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
252
+ ? data.pid
253
+ : undefined,
254
+ userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
255
+ savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
256
+ };
185
257
  }
@@ -94,7 +94,7 @@ export const stateCommand: Command = {
94
94
  await browser.clearLocalStorage(c, sid);
95
95
  await browser.clearSessionStorage(c, sid);
96
96
  session.refs = new Map();
97
- await browser.clearRefCache();
97
+ await browser.clearRefCache(session.port);
98
98
  output.printSuccess('Browser state cleared (cookies, localStorage, sessionStorage, refs)');
99
99
  return { success: true };
100
100
  }
@@ -322,7 +322,7 @@ export const navigateCommand: Command = {
322
322
 
323
323
  // Refs captured before this navigation may now point at different content.
324
324
  session.refs = new Map();
325
- await browser.clearRefCache();
325
+ await browser.clearRefCache(session.port);
326
326
 
327
327
  output.printSuccess(`Navigated: ${direction}`);
328
328
  return { success: true };
@@ -435,7 +435,7 @@ export const pushstateCommand: Command = {
435
435
  // SPA navigation changes what's on the page without a full page load —
436
436
  // refs captured before this pushState call may now resolve to different content.
437
437
  session.refs = new Map();
438
- await browser.clearRefCache();
438
+ await browser.clearRefCache(session.port);
439
439
  output.printSuccess(`pushState: ${url}`);
440
440
  return { success: true };
441
441
  },
@@ -71,7 +71,7 @@ export const snapshotCommand: Command = {
71
71
  });
72
72
 
73
73
  session.refs = result.refs;
74
- await browser.saveRefCache(session.targetId, result.url, session.refs);
74
+ await browser.saveRefCache(session.port, session.targetId, result.url, session.refs);
75
75
 
76
76
  const applyOutputLimits = (text: string): string => {
77
77
  const maxOutput = ctx.flags['max-output'] as number | undefined;
@@ -375,7 +375,7 @@ export const diffCommand: Command = {
375
375
  });
376
376
 
377
377
  session.refs = snap2.refs;
378
- await browser.saveRefCache(session.targetId, snap2.url, session.refs);
378
+ await browser.saveRefCache(session.port, session.targetId, snap2.url, session.refs);
379
379
 
380
380
  // Text diff
381
381
  const lines1 = snap1.text.split('\n');
@@ -210,8 +210,8 @@ export const reportCommand: Command = {
210
210
  session.sessionId = '';
211
211
  session.targetId = '';
212
212
  session.refs = new Map();
213
- await browser.clearActivePort();
214
- await browser.clearRefCache();
213
+ await browser.removeSessionRecord(session.port);
214
+ await browser.clearRefCache(session.port);
215
215
  };
216
216
 
217
217
  let result:
@@ -10,10 +10,13 @@
10
10
 
11
11
  import { output } from './output.js';
12
12
  import {
13
+ adoptLegacySession,
13
14
  detectAttentionNeeded,
14
15
  ensureConnected,
15
- ensureSignalCleanupHandlers,
16
16
  getBrowser,
17
+ launchSessionBrowser,
18
+ pinnedPort,
19
+ resolveLiveSession,
17
20
  session,
18
21
  switchToHeaded,
19
22
  } from './session.js';
@@ -23,7 +26,13 @@ export const openCommand: Command = {
23
26
  name: 'open',
24
27
  description: 'Open a URL in the browser. Usage: monomind browse open <url>',
25
28
  options: [
26
- { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
29
+ {
30
+ name: 'port',
31
+ short: 'p',
32
+ type: 'number',
33
+ description:
34
+ 'Attach to (or launch on) this CDP port. Default: a free port of this session’s own',
35
+ },
27
36
  {
28
37
  name: 'headed',
29
38
  type: 'boolean',
@@ -37,7 +46,10 @@ export const openCommand: Command = {
37
46
  const url = ctx.args[0] as string;
38
47
  if (!url) throw new Error('URL required. Usage: monomind browse open <url>');
39
48
 
40
- const port = (ctx.flags.port as number) ?? 9222;
49
+ // No --port means "start a session of my own on a free port" (#318) —
50
+ // 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);
41
53
  const forceHeaded = ctx.flags.headed as boolean;
42
54
  const browser = await getBrowser();
43
55
 
@@ -77,38 +89,9 @@ export const openCommand: Command = {
77
89
  session.refs = new Map();
78
90
  }
79
91
 
80
- session.port = await browser.launchBrowser({ port, headless: !forceHeaded });
81
- // Persist the active port so subsequent CLI invocations (each a fresh
82
- // process) default to attaching here instead of hardcoded 9222. Also
83
- // persist the launched PID/userDataDir so a later process's closeBrowser
84
- // can still kill this Chrome even though launchedPids (browser.ts) is
85
- // per-process and empty there.
86
- //
87
- // launchBrowser() can either LAUNCH a fresh Chrome or ATTACH to one
88
- // already listening on the requested port (see its own "attach if
89
- // already Chrome" comment) — it returns only a port number, with no
90
- // signal telling this caller which happened. getLaunchedPid(session.port) is
91
- // undefined on the attach path (this process never spawned anything).
92
- // Bug fixed here: unconditionally saving {pid: undefined, ...} on
93
- // attach used to CLOBBER a real PID a previous `open` had already
94
- // persisted for this exact port, destroying the only way a later
95
- // process's closeBrowser() PID-kill fallback could ever find it.
96
- const freshPid = browser.getLaunchedPid(session.port);
97
- const freshUserDataDir = browser.getLaunchedUserDataDir(session.port);
98
- if (freshPid !== undefined) {
99
- await browser.saveActivePort(session.port, { pid: freshPid, userDataDir: freshUserDataDir });
100
- } else {
101
- // Attach path: preserve whatever PID/userDataDir was already on file
102
- // for this port (most likely from the process that originally
103
- // launched it) instead of overwriting with undefined.
104
- const existing = await browser.loadActivePortInfo();
105
- const preserved = existing && existing.port === session.port ? existing : undefined;
106
- await browser.saveActivePort(session.port, {
107
- pid: preserved?.pid,
108
- userDataDir: preserved?.userDataDir,
109
- });
110
- }
111
- ensureSignalCleanupHandlers();
92
+ // Starts this session's own browser (and records it) unless --port named
93
+ // one to attach to — see the session rule in session.ts.
94
+ session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
112
95
  const conn = await browser.connectToTarget(session.port);
113
96
  session.client = conn.client;
114
97
  session.sessionId = conn.sessionId;
@@ -117,7 +100,7 @@ export const openCommand: Command = {
117
100
  // A snapshot taken before this navigation is no longer valid for the new
118
101
  // page — drop the persisted ref cache so a stale process's weak
119
102
  // time-based check can't resurrect it before the next explicit snapshot.
120
- await browser.clearRefCache();
103
+ await browser.clearRefCache(session.port);
121
104
 
122
105
  if (ctx.flags.state && ctx.flags.session) {
123
106
  output.printWarning('Both --state and --session provided; --state takes precedence');
@@ -142,7 +125,7 @@ export const openCommand: Command = {
142
125
  output.printWarning(
143
126
  `${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`,
144
127
  );
145
- await switchToHeaded(currentUrl, port);
128
+ await switchToHeaded(currentUrl, session.port);
146
129
  await browser.openUrl(session.client!, session.sessionId, currentUrl);
147
130
  output.printSuccess(
148
131
  `Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`,
@@ -153,15 +136,27 @@ export const openCommand: Command = {
153
136
  const finalUrl = await browser.getCurrentUrl(session.client!, session.sessionId);
154
137
  const title = await browser.getCurrentTitle(session.client!, session.sessionId);
155
138
 
156
- output.printSuccess(`Opened: ${title} (${finalUrl})`);
157
- return { success: true, data: { url: finalUrl, title } };
139
+ // The port is part of the result, not decoration: it is this session's
140
+ // handle, and the only way a concurrent caller can pin it from another
141
+ // process (`--port N`) instead of resolving "the newest live session".
142
+ output.printSuccess(`Opened: ${title} (${finalUrl}) [port ${session.port}]`);
143
+ return { success: true, data: { url: finalUrl, title, port: session.port } };
158
144
  },
159
145
  };
160
146
 
161
147
  export const closeCommand: Command = {
162
148
  name: 'close',
163
- description: 'Close the active browser session',
164
- action: async (_ctx: CommandContext): Promise<CommandResult> => {
149
+ description:
150
+ 'Close a browser session. Usage: monomind browse close [--port <port>] (default: the newest live session here)',
151
+ options: [
152
+ {
153
+ name: 'port',
154
+ short: 'p',
155
+ type: 'number',
156
+ description: 'Close the session on this CDP port instead of the newest live one',
157
+ },
158
+ ],
159
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
165
160
  if (session.client) {
166
161
  const browser = await getBrowser();
167
162
  const sid = session.sessionId;
@@ -192,26 +187,48 @@ export const closeCommand: Command = {
192
187
  browser.stopRequestCapture(sid);
193
188
  browser.teardownDialogHandling(sid);
194
189
  browser.teardownConsoleCapture(sid);
190
+ // Terminate the browser this process launched, not just our socket to
191
+ // it: with a session of its own on a free port (#318), a Chrome left
192
+ // behind here is one nothing else knows how to find. A browser we
193
+ // merely attached to (`connect`) is the user's — never killed.
194
+ const record = await browser.loadSessionRecord(session.port);
195
+ if (record?.launched !== false) {
196
+ try {
197
+ await browser.closeBrowser(client, session.port);
198
+ } catch {
199
+ /* best-effort — the record cleanup below still runs */
200
+ }
201
+ }
195
202
  client.close();
196
203
  session.client = null;
197
204
  session.sessionId = '';
198
205
  session.parentSessionId = '';
199
206
  session.targetId = '';
200
207
  session.refs = new Map();
201
- // Session is gone — forget the persisted port and stale refs so later
208
+ // This session is gone — forget its record and stale refs so later
202
209
  // invocations don't chase a dead endpoint (or the wrong elements).
203
- await browser.clearActivePort();
204
- await browser.clearRefCache();
205
- output.printSuccess('Browser session closed');
210
+ // Other sessions' records are left alone.
211
+ await browser.removeSessionRecord(session.port);
212
+ await browser.clearRefCache(session.port);
213
+ output.printSuccess(`Browser session closed (port ${session.port})`);
206
214
  } else {
207
215
  // Each CLI invocation is a fresh process, so `close` almost always
208
- // lands here. The persisted port file is the real session handle:
209
- // if monobrowse LAUNCHED that browser (open), gracefully Browser.close
216
+ // lands here. The session record is the real handle: `--port` names
217
+ // one, otherwise it is the newest session whose browser still answers
218
+ // (strict:false — a dead one is cleaned up, not raised as an error).
219
+ // If monobrowse LAUNCHED that browser (open), gracefully Browser.close
210
220
  // it — otherwise every open→close cycle leaks a headless Chrome. If we
211
221
  // merely ATTACHED to it (connect, launched:false), never kill it: it's
212
- // the user's own browser. Either way, forget the port and refs.
222
+ // the user's own browser. Either way, forget that one session.
213
223
  const browser = await getBrowser();
214
- const persisted = await browser.loadActivePortInfo();
224
+ const pinned = pinnedPort(ctx.flags);
225
+ const persisted = pinned
226
+ ? ((await browser.loadSessionRecord(pinned)) ??
227
+ (await adoptLegacySession(browser, { port: pinned })) ?? {
228
+ port: pinned,
229
+ launched: true,
230
+ })
231
+ : await resolveLiveSession(browser, { strict: false });
215
232
  if (persisted?.launched) {
216
233
  try {
217
234
  const conn = await browser.connectToTarget(persisted.port);
@@ -257,8 +274,10 @@ export const closeCommand: Command = {
257
274
  : 'No active browser session',
258
275
  );
259
276
  }
260
- await browser.clearActivePort();
261
- await browser.clearRefCache();
277
+ if (persisted) {
278
+ await browser.removeSessionRecord(persisted.port);
279
+ await browser.clearRefCache(persisted.port);
280
+ }
262
281
  }
263
282
  return { success: true };
264
283
  },
@@ -346,12 +365,12 @@ export const connectCommand: Command = {
346
365
  session.targetId = conn.target.id;
347
366
  session.port = port;
348
367
  session.refs = new Map();
349
- // Persist the port like `open` does — without this, the NEXT CLI process
350
- // (each command is a fresh process) resolves the hardcoded default and
351
- // tries to launch its own Chrome on 9222 instead of reusing this session.
368
+ // Record the session like `open` does — without this, the NEXT CLI
369
+ // process (each command is a fresh process) has nothing to resolve and
370
+ // launches a browser of its own instead of reusing this session.
352
371
  // launched:false marks this browser as someone else's — close must never
353
372
  // kill it, and a dead endpoint must not be silently relaunched.
354
- await browser.saveActivePort(port, { launched: false });
373
+ await browser.saveSessionRecord(port, { launched: false });
355
374
  const url = await browser.getCurrentUrl(session.client, session.sessionId);
356
375
  const title = await browser.getCurrentTitle(session.client, session.sessionId);
357
376
  output.printSuccess(`Connected: ${title} (${url})`);