@monoes/monobrowse 1.0.13 → 1.0.15

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 (111) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/browser/bridge.d.ts +119 -0
  14. package/dist/src/browser/bridge.d.ts.map +1 -0
  15. package/dist/src/browser/bridge.js +264 -0
  16. package/dist/src/browser/bridge.js.map +1 -0
  17. package/dist/src/browser/browser.d.ts.map +1 -1
  18. package/dist/src/browser/browser.js +11 -0
  19. package/dist/src/browser/browser.js.map +1 -1
  20. package/dist/src/browser/cdp.d.ts +11 -1
  21. package/dist/src/browser/cdp.d.ts.map +1 -1
  22. package/dist/src/browser/cdp.js +60 -56
  23. package/dist/src/browser/cdp.js.map +1 -1
  24. package/dist/src/browser/index.d.ts +2 -0
  25. package/dist/src/browser/index.d.ts.map +1 -1
  26. package/dist/src/browser/index.js +2 -0
  27. package/dist/src/browser/index.js.map +1 -1
  28. package/dist/src/browser/transport.d.ts +62 -0
  29. package/dist/src/browser/transport.d.ts.map +1 -0
  30. package/dist/src/browser/transport.js +65 -0
  31. package/dist/src/browser/transport.js.map +1 -0
  32. package/dist/src/cli/commander-adapter.d.ts +16 -0
  33. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  34. package/dist/src/cli/commander-adapter.js +68 -0
  35. package/dist/src/cli/commander-adapter.js.map +1 -0
  36. package/dist/src/cli/commands-context.d.ts +14 -0
  37. package/dist/src/cli/commands-context.d.ts.map +1 -0
  38. package/dist/src/cli/commands-context.js +351 -0
  39. package/dist/src/cli/commands-context.js.map +1 -0
  40. package/dist/src/cli/commands-data.d.ts +12 -0
  41. package/dist/src/cli/commands-data.d.ts.map +1 -0
  42. package/dist/src/cli/commands-data.js +246 -0
  43. package/dist/src/cli/commands-data.js.map +1 -0
  44. package/dist/src/cli/commands-element.d.ts +15 -0
  45. package/dist/src/cli/commands-element.d.ts.map +1 -0
  46. package/dist/src/cli/commands-element.js +262 -0
  47. package/dist/src/cli/commands-element.js.map +1 -0
  48. package/dist/src/cli/commands-files.d.ts +9 -0
  49. package/dist/src/cli/commands-files.d.ts.map +1 -0
  50. package/dist/src/cli/commands-files.js +207 -0
  51. package/dist/src/cli/commands-files.js.map +1 -0
  52. package/dist/src/cli/commands-input.d.ts +26 -0
  53. package/dist/src/cli/commands-input.d.ts.map +1 -0
  54. package/dist/src/cli/commands-input.js +399 -0
  55. package/dist/src/cli/commands-input.js.map +1 -0
  56. package/dist/src/cli/commands-navigate.d.ts +12 -0
  57. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  58. package/dist/src/cli/commands-navigate.js +389 -0
  59. package/dist/src/cli/commands-navigate.js.map +1 -0
  60. package/dist/src/cli/commands-page.d.ts +25 -0
  61. package/dist/src/cli/commands-page.d.ts.map +1 -0
  62. package/dist/src/cli/commands-page.js +377 -0
  63. package/dist/src/cli/commands-page.js.map +1 -0
  64. package/dist/src/cli/commands-report.d.ts +8 -0
  65. package/dist/src/cli/commands-report.d.ts.map +1 -0
  66. package/dist/src/cli/commands-report.js +256 -0
  67. package/dist/src/cli/commands-report.js.map +1 -0
  68. package/dist/src/cli/commands-script.d.ts +43 -0
  69. package/dist/src/cli/commands-script.d.ts.map +1 -0
  70. package/dist/src/cli/commands-script.js +168 -0
  71. package/dist/src/cli/commands-script.js.map +1 -0
  72. package/dist/src/cli/commands-session.d.ts +15 -0
  73. package/dist/src/cli/commands-session.d.ts.map +1 -0
  74. package/dist/src/cli/commands-session.js +357 -0
  75. package/dist/src/cli/commands-session.js.map +1 -0
  76. package/dist/src/cli/commands-trace.d.ts +12 -0
  77. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  78. package/dist/src/cli/commands-trace.js +347 -0
  79. package/dist/src/cli/commands-trace.js.map +1 -0
  80. package/dist/src/cli/commands.d.ts +22 -42
  81. package/dist/src/cli/commands.d.ts.map +1 -1
  82. package/dist/src/cli/commands.js +42 -3648
  83. package/dist/src/cli/commands.js.map +1 -1
  84. package/dist/src/cli/session.d.ts +47 -0
  85. package/dist/src/cli/session.d.ts.map +1 -0
  86. package/dist/src/cli/session.js +343 -0
  87. package/dist/src/cli/session.js.map +1 -0
  88. package/dist/tsconfig.tsbuildinfo +1 -1
  89. package/package.json +1 -1
  90. package/src/__tests__/bridge-instruments.test.ts +180 -0
  91. package/src/__tests__/bridge-transport.test.ts +365 -0
  92. package/src/__tests__/cdp-transport.test.ts +198 -0
  93. package/src/browser/bridge.ts +354 -0
  94. package/src/browser/browser.ts +12 -0
  95. package/src/browser/cdp.ts +57 -52
  96. package/src/browser/index.ts +2 -0
  97. package/src/browser/transport.ts +111 -0
  98. package/src/cli/commander-adapter.ts +65 -0
  99. package/src/cli/commands-context.ts +367 -0
  100. package/src/cli/commands-data.ts +248 -0
  101. package/src/cli/commands-element.ts +305 -0
  102. package/src/cli/commands-files.ts +248 -0
  103. package/src/cli/commands-input.ts +434 -0
  104. package/src/cli/commands-navigate.ts +442 -0
  105. package/src/cli/commands-page.ts +420 -0
  106. package/src/cli/commands-report.ts +275 -0
  107. package/src/cli/commands-script.ts +183 -0
  108. package/src/cli/commands-session.ts +376 -0
  109. package/src/cli/commands-trace.ts +349 -0
  110. package/src/cli/commands.ts +95 -3943
  111. package/src/cli/session.ts +412 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Running things in the page — `eval` for a single expression, `batch` for a
3
+ * sequence of browse subcommands in one process, and the init scripts that run
4
+ * before every navigation.
5
+ *
6
+ * The tokenizer is shared: a batch line is parsed the same way the CLI would
7
+ * have parsed it on the command line, so `eval` expressions survive quoting.
8
+ */
9
+
10
+ import { output } from './output.js';
11
+ import {
12
+ DEFAULT_EVAL_MAX_OUTPUT,
13
+ ensureConnected,
14
+ getBrowser,
15
+ print,
16
+ session,
17
+ truncateForOutput,
18
+ } from './session.js';
19
+ import type { Command, CommandContext, CommandResult } from './types.js';
20
+
21
+ export const evalCommand: Command = {
22
+ name: 'eval',
23
+ description: 'Evaluate JavaScript in page context. Usage: monomind browse eval "document.title"',
24
+ options: [
25
+ { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
26
+ {
27
+ name: 'stdin',
28
+ type: 'boolean',
29
+ description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)',
30
+ default: false,
31
+ },
32
+ {
33
+ name: 'max-output',
34
+ type: 'number',
35
+ description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)`,
36
+ },
37
+ {
38
+ name: 'timeout',
39
+ type: 'number',
40
+ description: 'Max ms to wait for evaluation to settle (default 30000)',
41
+ },
42
+ ],
43
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
44
+ const { client, sessionId } = await ensureConnected(session.port);
45
+ const browser = await getBrowser();
46
+
47
+ let expr = ctx.args[0] as string;
48
+ if (ctx.flags.stdin) {
49
+ const chunks: Buffer[] = [];
50
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
51
+ expr = Buffer.concat(chunks).toString('utf8').trim();
52
+ }
53
+ if (!expr) throw new Error('Usage: monomind browse eval "<expression>" (or pipe with --stdin)');
54
+
55
+ const timeoutMs = ctx.flags.timeout as number | undefined;
56
+ const result = await browser.evaluateJs(client, sessionId, expr, timeoutMs);
57
+
58
+ const maxOutput = (ctx.flags['max-output'] as number | undefined) ?? DEFAULT_EVAL_MAX_OUTPUT;
59
+ if (ctx.flags.json) {
60
+ print(truncateForOutput(JSON.stringify({ data: result }), maxOutput));
61
+ } else {
62
+ print(truncateForOutput(String(result ?? ''), maxOutput));
63
+ }
64
+
65
+ return { success: true, data: { result } };
66
+ },
67
+ };
68
+
69
+ function tokenizeBatchCommand(input: string): string[] {
70
+ const tokens: string[] = [];
71
+ let current = '';
72
+ let inQuote: '"' | "'" | null = null;
73
+ for (const ch of input.trim()) {
74
+ if (inQuote) {
75
+ if (ch === inQuote) {
76
+ inQuote = null;
77
+ } else {
78
+ current += ch;
79
+ }
80
+ } else if (ch === '"' || ch === "'") {
81
+ inQuote = ch;
82
+ } else if (/\s/.test(ch)) {
83
+ if (current) {
84
+ tokens.push(current);
85
+ current = '';
86
+ }
87
+ } else {
88
+ current += ch;
89
+ }
90
+ }
91
+ if (current) tokens.push(current);
92
+ return tokens;
93
+ }
94
+
95
+ /**
96
+ * Splits one line of a `batch` command string into a subcommand name, its
97
+ * args, and any recognized leading flags.
98
+ *
99
+ * `eval` is special-cased: its sole argument is a raw JS expression, which
100
+ * legitimately contains its own string-literal quotes (e.g.
101
+ * `eval document.querySelector('a')`). tokenizeBatchCommand's shell-style
102
+ * word-splitting treats `'...'`/`"..."` as grouping delimiters and discards
103
+ * them — correct for a value like `fill @e1 "some text"`, but for `eval` it
104
+ * silently deletes the expression's own quotes, turning a string literal
105
+ * into a bare (undefined) identifier reference and producing a
106
+ * ReferenceError instead of evaluating the intended expression. For `eval`,
107
+ * only its own known --flags are consumed from the front; everything after
108
+ * them is taken verbatim as one argument, untouched by tokenization.
109
+ *
110
+ /**
111
+ * `get box`'s output shape: browser.getElementBox() returns the element's
112
+ * CENTER point under x/y (correct for internal click-target callers), but
113
+ * `get box` is a bounding-box accessor where x/y conventionally means the
114
+ * top-left origin — a caller computing its own center as `box.x + width/2`
115
+ * would otherwise double-offset away from the element (issue #15). Expose
116
+ * both conventions, explicitly labeled, so neither is ambiguous.
117
+ *
118
+ * Exported for direct unit testing — not part of the CLI's public API.
119
+ */
120
+
121
+ export function parseBatchCommandLine(cmdStr: string): {
122
+ subName: string;
123
+ subArgs: string[];
124
+ flags: Record<string, unknown>;
125
+ } {
126
+ const trimmed = cmdStr.trim();
127
+ const evalMatch = trimmed.match(/^eval\b\s*/);
128
+ if (!evalMatch) {
129
+ const parts = tokenizeBatchCommand(trimmed);
130
+ return { subName: parts[0], subArgs: parts.slice(1), flags: {} };
131
+ }
132
+
133
+ let rest = trimmed.slice(evalMatch[0].length);
134
+ const flags: Record<string, unknown> = {};
135
+ let consumedAnother = true;
136
+ while (consumedAnother) {
137
+ consumedAnother = false;
138
+ const withValue = rest.match(/^--(max-output|timeout)\s+(-?\d+)\s*/);
139
+ if (withValue) {
140
+ flags[withValue[1]] = Number(withValue[2]);
141
+ rest = rest.slice(withValue[0].length);
142
+ consumedAnother = true;
143
+ continue;
144
+ }
145
+ const boolFlag = rest.match(/^--(json|stdin)\b\s*/);
146
+ if (boolFlag) {
147
+ flags[boolFlag[1]] = true;
148
+ rest = rest.slice(boolFlag[0].length);
149
+ consumedAnother = true;
150
+ }
151
+ }
152
+ return { subName: 'eval', subArgs: [rest], flags };
153
+ }
154
+
155
+ export const addinitscriptCommand: Command = {
156
+ name: 'addinitscript',
157
+ description:
158
+ 'Add script to run before page navigation. Usage: monomind browse addinitscript "window.x=1"',
159
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
160
+ const { client, sessionId } = await ensureConnected(session.port);
161
+ const browser = await getBrowser();
162
+ const script = ctx.args[0] as string;
163
+ if (!script) throw new Error('Usage: monomind browse addinitscript "<js>"');
164
+ const id = await browser.addInitScript(client, sessionId, script);
165
+ output.printSuccess(`Init script added: ${id}`);
166
+ return { success: true, data: { identifier: id } };
167
+ },
168
+ };
169
+
170
+ export const removeinitscriptCommand: Command = {
171
+ name: 'removeinitscript',
172
+ description:
173
+ 'Remove a previously added init script. Usage: monomind browse removeinitscript <id>',
174
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
175
+ const { client, sessionId } = await ensureConnected(session.port);
176
+ const browser = await getBrowser();
177
+ const id = ctx.args[0] as string;
178
+ if (!id) throw new Error('Usage: monomind browse removeinitscript <identifier>');
179
+ await browser.removeInitScript(client, sessionId, id);
180
+ output.printSuccess(`Init script removed: ${id}`);
181
+ return { success: true };
182
+ },
183
+ };
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Session lifecycle — starting a browser session, attaching to one someone
3
+ * else started, and ending it.
4
+ *
5
+ * `open` launches (or reuses) headless Chrome and is the only command that can
6
+ * escalate to a headed window for a login/CAPTCHA wall. `connect` attaches to
7
+ * a browser the user already runs, which is why `close` treats the two
8
+ * differently: it must never kill a Chrome it did not launch.
9
+ */
10
+
11
+ import { output } from './output.js';
12
+ import {
13
+ detectAttentionNeeded,
14
+ ensureConnected,
15
+ ensureSignalCleanupHandlers,
16
+ getBrowser,
17
+ session,
18
+ switchToHeaded,
19
+ } from './session.js';
20
+ import type { Command, CommandContext, CommandResult } from './types.js';
21
+
22
+ export const openCommand: Command = {
23
+ name: 'open',
24
+ description: 'Open a URL in the browser. Usage: monomind browse open <url>',
25
+ options: [
26
+ { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
27
+ {
28
+ name: 'headed',
29
+ type: 'boolean',
30
+ description: 'Force visible browser window',
31
+ default: false,
32
+ },
33
+ { name: 'session', short: 's', type: 'string', description: 'Session name to restore' },
34
+ { name: 'state', type: 'string', description: 'State file to load' },
35
+ ],
36
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
37
+ const url = ctx.args[0] as string;
38
+ if (!url) throw new Error('URL required. Usage: monomind browse open <url>');
39
+
40
+ const port = (ctx.flags.port as number) ?? 9222;
41
+ const forceHeaded = ctx.flags.headed as boolean;
42
+ const browser = await getBrowser();
43
+
44
+ if (session.client) {
45
+ const prevSid = session.sessionId;
46
+ const prevClient = session.client;
47
+ if (browser.getHarStatus(prevSid).recording) {
48
+ try {
49
+ await browser.stopHarRecording(prevClient, prevSid);
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+ if (browser.getTraceStatus(prevSid)) {
55
+ try {
56
+ await browser.stopTrace(prevClient, prevSid);
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+ if (browser.isProfilingActive(prevSid)) {
62
+ try {
63
+ await browser.stopCpuProfile(prevClient, prevSid);
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ }
68
+ browser.teardownRouteInterception(prevSid);
69
+ browser.stopRequestCapture(prevSid);
70
+ browser.teardownDialogHandling(prevSid);
71
+ browser.teardownConsoleCapture(prevSid);
72
+ prevClient.close();
73
+ session.client = null;
74
+ session.sessionId = '';
75
+ session.parentSessionId = '';
76
+ session.targetId = '';
77
+ session.refs = new Map();
78
+ }
79
+
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();
112
+ const conn = await browser.connectToTarget(session.port);
113
+ session.client = conn.client;
114
+ session.sessionId = conn.sessionId;
115
+ session.targetId = conn.target.id;
116
+ session.refs = new Map();
117
+ // A snapshot taken before this navigation is no longer valid for the new
118
+ // page — drop the persisted ref cache so a stale process's weak
119
+ // time-based check can't resurrect it before the next explicit snapshot.
120
+ await browser.clearRefCache();
121
+
122
+ if (ctx.flags.state && ctx.flags.session) {
123
+ output.printWarning('Both --state and --session provided; --state takes precedence');
124
+ }
125
+ if (ctx.flags.state) {
126
+ await browser.loadStateFile(session.client, session.sessionId, ctx.flags.state as string);
127
+ } else if (ctx.flags.session) {
128
+ await browser.loadSession(session.client, session.sessionId, ctx.flags.session as string);
129
+ }
130
+
131
+ await browser.openUrl(session.client, session.sessionId, url);
132
+ const currentUrl = await browser.getCurrentUrl(session.client, session.sessionId);
133
+
134
+ // Auto-detect login/CAPTCHA walls and switch to headed if needed
135
+ if (!forceHeaded) {
136
+ const attentionType = await detectAttentionNeeded(
137
+ session.client,
138
+ session.sessionId,
139
+ currentUrl,
140
+ );
141
+ if (attentionType) {
142
+ output.printWarning(
143
+ `${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`,
144
+ );
145
+ await switchToHeaded(currentUrl, port);
146
+ await browser.openUrl(session.client!, session.sessionId, currentUrl);
147
+ output.printSuccess(
148
+ `Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`,
149
+ );
150
+ }
151
+ }
152
+
153
+ const finalUrl = await browser.getCurrentUrl(session.client!, session.sessionId);
154
+ const title = await browser.getCurrentTitle(session.client!, session.sessionId);
155
+
156
+ output.printSuccess(`Opened: ${title} (${finalUrl})`);
157
+ return { success: true, data: { url: finalUrl, title } };
158
+ },
159
+ };
160
+
161
+ export const closeCommand: Command = {
162
+ name: 'close',
163
+ description: 'Close the active browser session',
164
+ action: async (_ctx: CommandContext): Promise<CommandResult> => {
165
+ if (session.client) {
166
+ const browser = await getBrowser();
167
+ const sid = session.sessionId;
168
+ const client = session.client;
169
+ // Tear down per-session Maps and listeners before closing
170
+ if (browser.getHarStatus(sid).recording) {
171
+ try {
172
+ await browser.stopHarRecording(client, sid);
173
+ } catch {
174
+ /* ignore */
175
+ }
176
+ }
177
+ if (browser.getTraceStatus(sid)) {
178
+ try {
179
+ await browser.stopTrace(client, sid);
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ }
184
+ if (browser.isProfilingActive(sid)) {
185
+ try {
186
+ await browser.stopCpuProfile(client, sid);
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ }
191
+ browser.teardownRouteInterception(sid);
192
+ browser.stopRequestCapture(sid);
193
+ browser.teardownDialogHandling(sid);
194
+ browser.teardownConsoleCapture(sid);
195
+ client.close();
196
+ session.client = null;
197
+ session.sessionId = '';
198
+ session.parentSessionId = '';
199
+ session.targetId = '';
200
+ session.refs = new Map();
201
+ // Session is gone — forget the persisted port and stale refs so later
202
+ // 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');
206
+ } else {
207
+ // 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
210
+ // it — otherwise every open→close cycle leaks a headless Chrome. If we
211
+ // 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.
213
+ const browser = await getBrowser();
214
+ const persisted = await browser.loadActivePortInfo();
215
+ if (persisted?.launched) {
216
+ try {
217
+ const conn = await browser.connectToTarget(persisted.port);
218
+ try {
219
+ await browser.closeBrowser(conn.client, persisted.port);
220
+ } finally {
221
+ // Always drop our websocket — a hung Browser.close must not keep
222
+ // this CLI process's event loop alive.
223
+ try {
224
+ conn.client.close();
225
+ } catch {
226
+ /* already gone */
227
+ }
228
+ }
229
+ // closeBrowser has no PID fallback in a fresh process (launchedPids
230
+ // is per-process) — re-probe so we report what actually happened.
231
+ // Poll briefly: Browser.close is acknowledged before the process
232
+ // actually exits, so a single immediate probe false-alarms.
233
+ let stillUp = true;
234
+ const probeDeadline = Date.now() + 3000;
235
+ while (stillUp && Date.now() < probeDeadline) {
236
+ try {
237
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
238
+ signal: AbortSignal.timeout(800),
239
+ });
240
+ await new Promise((r) => setTimeout(r, 300));
241
+ } catch {
242
+ stillUp = false;
243
+ }
244
+ }
245
+ if (stillUp)
246
+ output.printWarning(
247
+ `Browser on port ${persisted.port} did not exit — kill it manually if needed`,
248
+ );
249
+ else output.printSuccess(`Closed browser on port ${persisted.port}`);
250
+ } catch {
251
+ output.printInfo(`No browser answering on port ${persisted.port} — nothing to close`);
252
+ }
253
+ } else {
254
+ output.printInfo(
255
+ persisted
256
+ ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
257
+ : 'No active browser session',
258
+ );
259
+ }
260
+ await browser.clearActivePort();
261
+ await browser.clearRefCache();
262
+ }
263
+ return { success: true };
264
+ },
265
+ };
266
+
267
+ export const connectCommand: Command = {
268
+ name: 'connect',
269
+ description:
270
+ '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]',
271
+ options: [
272
+ { name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
273
+ { name: 'target', type: 'string', description: 'Target ID to attach to' },
274
+ {
275
+ name: 'auto-connect',
276
+ type: 'boolean',
277
+ description: 'Auto-discover running Chrome on ports 9222 and 9229',
278
+ default: false,
279
+ },
280
+ ],
281
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
282
+ let port = (ctx.flags.port as number) ?? 9222;
283
+
284
+ if (ctx.flags['auto-connect']) {
285
+ const probePorts = [9222, 9229];
286
+ let found = false;
287
+ for (const p of probePorts) {
288
+ try {
289
+ const r = await fetch(`http://127.0.0.1:${p}/json/version`);
290
+ if (r.ok) {
291
+ port = p;
292
+ found = true;
293
+ break;
294
+ }
295
+ } catch {
296
+ /* port not open */
297
+ }
298
+ }
299
+ if (!found)
300
+ throw new Error(
301
+ 'No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --port.',
302
+ );
303
+ }
304
+
305
+ const browser = await getBrowser();
306
+
307
+ if (session.client) {
308
+ const prevSid = session.sessionId;
309
+ const prevClient = session.client;
310
+ if (browser.getHarStatus(prevSid).recording) {
311
+ try {
312
+ await browser.stopHarRecording(prevClient, prevSid);
313
+ } catch {
314
+ /* ignore */
315
+ }
316
+ }
317
+ if (browser.getTraceStatus(prevSid)) {
318
+ try {
319
+ await browser.stopTrace(prevClient, prevSid);
320
+ } catch {
321
+ /* ignore */
322
+ }
323
+ }
324
+ if (browser.isProfilingActive(prevSid)) {
325
+ try {
326
+ await browser.stopCpuProfile(prevClient, prevSid);
327
+ } catch {
328
+ /* ignore */
329
+ }
330
+ }
331
+ browser.teardownRouteInterception(prevSid);
332
+ browser.stopRequestCapture(prevSid);
333
+ browser.teardownDialogHandling(prevSid);
334
+ browser.teardownConsoleCapture(prevSid);
335
+ prevClient.close();
336
+ session.client = null;
337
+ session.sessionId = '';
338
+ session.parentSessionId = '';
339
+ session.targetId = '';
340
+ session.refs = new Map();
341
+ }
342
+
343
+ const conn = await browser.connectToTarget(port, ctx.flags.target as string | undefined);
344
+ session.client = conn.client;
345
+ session.sessionId = conn.sessionId;
346
+ session.targetId = conn.target.id;
347
+ session.port = port;
348
+ 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.
352
+ // launched:false marks this browser as someone else's — close must never
353
+ // kill it, and a dead endpoint must not be silently relaunched.
354
+ await browser.saveActivePort(port, { launched: false });
355
+ const url = await browser.getCurrentUrl(session.client, session.sessionId);
356
+ const title = await browser.getCurrentTitle(session.client, session.sessionId);
357
+ output.printSuccess(`Connected: ${title} (${url})`);
358
+ return { success: true, data: { targetId: session.targetId, url, title } };
359
+ },
360
+ };
361
+
362
+ export const resizeCommand: Command = {
363
+ name: 'resize',
364
+ description: 'Resize browser window. Usage: monomind browse resize <width> <height>',
365
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
366
+ const { client, sessionId } = await ensureConnected(session.port);
367
+ const browser = await getBrowser();
368
+ const width = parseInt(ctx.args[0] as string, 10);
369
+ const height = parseInt(ctx.args[1] as string, 10);
370
+ if (Number.isNaN(width) || Number.isNaN(height))
371
+ throw new Error('Usage: monomind browse resize <width> <height>');
372
+ await browser.setViewport(client, sessionId, width, height);
373
+ output.printSuccess(`Resized to ${width}x${height}`);
374
+ return { success: true, data: { width, height } };
375
+ },
376
+ };