@thegitai/cli 1.0.0-preview.38 → 1.0.0-preview.39

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.
package/README.md CHANGED
@@ -43,8 +43,28 @@ CLI login tokens use a rolling 48-hour inactivity timeout. If one expires during
43
43
  any server request, the CLI removes the expired credential and signs you in
44
44
  again on the next run.
45
45
 
46
+ ## Chrome browser control
47
+
48
+ The TheGitAI Chrome extension is separately installed. After installing it,
49
+ start `ai` normally and ask about your current tab or a website task. It uses
50
+ your existing Chrome profile, website sessions, and terminal login. Connection
51
+ is automatic; no setup/start command, second sign-in, or manual tab attachment
52
+ is required. Tabs used by the agent join a TheGitAI tab group in an existing
53
+ window. The conversation stays in your terminal.
54
+
55
+ Default mode asks before using a website and can remember that site for the
56
+ session. Auto-Accept allows browser actions; Plan mode reads pages without
57
+ clicking, typing, or navigating. Use Stop/Resume in the extension popup to
58
+ pause browser control, or Disable to disconnect it. Chrome's protected pages,
59
+ including browser settings and extension management, cannot be controlled.
60
+
61
+ The extension is currently a development download and is not yet listed in the
62
+ Chrome Web Store. Its version and distribution are independent of this npm
63
+ package. Ubuntu with Chrome is the initial validation target.
64
+
46
65
  ## Structured questions
47
66
 
67
+
48
68
  The agent can pause its current turn to ask up to four related questions in one
49
69
  form. Use **↑ / ↓**, the displayed option number, or **Enter** to choose, and
50
70
  **← / →** to move between questions. **Something else / add details** is the
package/dist/bin/ai.js CHANGED
@@ -6,9 +6,10 @@ import { isSignInCancelled } from '../src/api/browser-login.js';
6
6
  import { runSignIn } from '../src/signin.js';
7
7
  import { STARTUP_RETRY_BUDGET, authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
8
8
  import { formatCliHelpText } from '../src/help-text.js';
9
- import { createSession } from '../src/session.js';
9
+ import { createSession, disposeSession } from '../src/session.js';
10
10
  import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../src/session-store.js';
11
11
  import { runClientInteractive } from '../src/ui/repl.js';
12
+ import { startBrowserDiscoveryInBackground } from '../src/browser/session-bridge.js';
12
13
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
13
14
  import { formatSessionExitNotice } from '../src/session-exit.js';
14
15
  import { formatUsageText } from '../src/usage.js';
@@ -237,26 +238,32 @@ export async function main() {
237
238
  if (sourceSnapshot) {
238
239
  applySessionSnapshot(session, sourceSnapshot);
239
240
  }
241
+ startBrowserDiscoveryInBackground({ cwd: rootDir, env: session.env });
240
242
  const initialPrompt = prompt || undefined;
241
- const outcome = await runClientInteractive({
242
- appendPromptHistory: (value) => appendPromptHistory(value, session.env),
243
- authConfig,
244
- debugUi: whoami.debugUi,
245
- serverModels,
246
- serverSessionClient,
247
- session,
248
- initialPrompt,
249
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
250
- });
251
- if (outcome.signedOut) {
252
- if (sessionHasUserMessage(session)) {
253
- saveSessionState(session);
243
+ try {
244
+ const outcome = await runClientInteractive({
245
+ appendPromptHistory: (value) => appendPromptHistory(value, session.env),
246
+ authConfig,
247
+ debugUi: whoami.debugUi,
248
+ serverModels,
249
+ serverSessionClient,
250
+ session,
251
+ initialPrompt,
252
+ usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
253
+ });
254
+ if (outcome.signedOut) {
255
+ if (sessionHasUserMessage(session)) {
256
+ saveSessionState(session);
257
+ }
258
+ console.log(chalk.green('\n✓ Logged out.\n'));
259
+ return;
254
260
  }
255
- console.log(chalk.green('\n✓ Logged out.\n'));
256
- return;
261
+ await saveSessionBoth({ session, serverSessionClient });
262
+ printSessionExit(session);
263
+ }
264
+ finally {
265
+ await disposeSession(session);
257
266
  }
258
- await saveSessionBoth({ session, serverSessionClient });
259
- printSessionExit(session);
260
267
  }
261
268
  main().catch((error) => {
262
269
  if (isSignInCancelled(error)) {
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import net from 'node:net';
6
+ import path from 'node:path';
7
+ import { decodeFrames, encodeFrame, fragmentPayload, MAX_FRAME_BYTES } from '../src/browser/framing.js';
8
+ import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, sessionKeyFromRecordFile, sessionSocketPath, socketIsFile, } from '../src/browser/protocol.js';
9
+ const SCAN_INTERVAL_MS = 1000;
10
+ const NUL = String.fromCharCode(0);
11
+ const sessions = new Map();
12
+ const runtimeDir = browserRuntimeDir(process.env);
13
+ let extensionVersion = '';
14
+ let extensionProfileId = '';
15
+ let extensionPlatform = '';
16
+ function log(message) {
17
+ process.stderr.write(`[thegitai-browser-host] ${message}\n`);
18
+ }
19
+ function writeToExtension(frame) {
20
+ const payload = Buffer.from(JSON.stringify(frame), 'utf8');
21
+ if (payload.length > MAX_FRAME_BYTES) {
22
+ for (const fragment of fragmentPayload(payload, crypto.randomUUID())) {
23
+ writeToExtension(fragment);
24
+ }
25
+ return;
26
+ }
27
+ process.stdout.write(encodeFrame(payload));
28
+ }
29
+ let inbound = Buffer.alloc(0);
30
+ process.stdin.on('data', (chunk) => {
31
+ inbound = Buffer.concat([inbound, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')]);
32
+ const { frames, rest, unparseable } = decodeFrames(inbound);
33
+ inbound = rest;
34
+ for (let i = 0; i < unparseable; i += 1) {
35
+ log('discarded an unparseable frame from the extension');
36
+ }
37
+ for (const frame of frames)
38
+ handleExtensionFrame(frame);
39
+ });
40
+ process.stdin.on('end', () => {
41
+ for (const link of sessions.values())
42
+ link.socket.destroy();
43
+ process.exit(0);
44
+ });
45
+ function describeParentBrowser() {
46
+ const cmdline = process.platform === 'win32' ? windowsParentCommandLine() : unixParentCommandLine();
47
+ return describeFromCommandLine(cmdline);
48
+ }
49
+ function windowsParentCommandLine() {
50
+ const PASS_THROUGH = /^(cmd|conhost|powershell|pwsh|node)\.exe$/i;
51
+ try {
52
+ const rows = execFileSync('powershell.exe', [
53
+ '-NoProfile',
54
+ '-NonInteractive',
55
+ '-Command',
56
+ 'Get-CimInstance Win32_Process | ForEach-Object {' +
57
+ ' "{0}`t{1}`t{2}`t{3}" -f $_.ProcessId, $_.ParentProcessId, $_.Name, $_.CommandLine }',
58
+ ], {
59
+ encoding: 'utf8',
60
+ timeout: 15000,
61
+ maxBuffer: 8 * 1024 * 1024,
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ });
64
+ const byPid = new Map();
65
+ for (const line of rows.split(/\r?\n/)) {
66
+ const [pid, parent, name, ...rest] = line.split('\t');
67
+ const id = Number(pid);
68
+ if (!Number.isInteger(id))
69
+ continue;
70
+ byPid.set(id, {
71
+ parent: Number(parent),
72
+ name: String(name ?? ''),
73
+ command: rest.join('\t'),
74
+ });
75
+ }
76
+ let current = byPid.get(process.ppid);
77
+ for (let hop = 0; hop < 4 && current && PASS_THROUGH.test(current.name); hop += 1) {
78
+ current = byPid.get(current.parent);
79
+ }
80
+ if (!current?.command)
81
+ return [];
82
+ return (current.command.match(/"[^"]*"|\S+/g) ?? []).map((token) => token.startsWith('"') && token.endsWith('"') ? token.slice(1, -1) : token);
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
88
+ function unixParentCommandLine() {
89
+ try {
90
+ return fs.readFileSync(`/proc/${process.ppid}/cmdline`, 'utf8').split(NUL);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ function describeFromCommandLine(cmdline) {
97
+ try {
98
+ if (cmdline.length === 0)
99
+ throw new Error('no command line');
100
+ const brand = path
101
+ .basename(cmdline[0] ?? '')
102
+ .replace(/\.exe$/i, '')
103
+ .replace(/[-_](browser|stable|beta|dev|unstable|bin)$/i, '')
104
+ .replace(/[-_]+/g, ' ')
105
+ .replace(/\b[a-z]/g, (letter) => letter.toUpperCase())
106
+ .trim() || 'Chrome';
107
+ const profileArg = cmdline.find((part) => part.startsWith('--profile-directory='));
108
+ const userDataArg = cmdline.find((part) => part.startsWith('--user-data-dir='));
109
+ const profile = profileArg ? profileArg.slice('--profile-directory='.length) : 'Default';
110
+ const suffix = userDataArg
111
+ ? ` (${path.basename(userDataArg.slice('--user-data-dir='.length))})`
112
+ : '';
113
+ return { browser: brand, profile: `${profile}${suffix}` };
114
+ }
115
+ catch {
116
+ return { browser: 'Chrome', profile: 'Default' };
117
+ }
118
+ }
119
+ const parent = describeParentBrowser();
120
+ function browserKey() {
121
+ const seed = extensionProfileId || `${parent.browser} ${parent.profile}`;
122
+ return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 12);
123
+ }
124
+ function identity() {
125
+ return {
126
+ key: browserKey(),
127
+ browser: parent.browser,
128
+ profile: parent.profile !== 'Default' || !extensionProfileId
129
+ ? parent.profile
130
+ : `profile ${extensionProfileId.slice(0, 6)}`,
131
+ platform: extensionPlatform || process.platform,
132
+ extensionVersion,
133
+ };
134
+ }
135
+ function handleExtensionFrame(frame) {
136
+ if (frame?.t === 'hello') {
137
+ extensionVersion = String(frame.extensionVersion ?? '');
138
+ extensionProfileId = String(frame.profileId ?? '');
139
+ extensionPlatform = String(frame.platform ?? '');
140
+ writeToExtension({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION });
141
+ publishSessions();
142
+ for (const link of sessions.values())
143
+ sendHello(link);
144
+ return;
145
+ }
146
+ if (frame?.t === 'res' || frame?.t === 'event') {
147
+ const target = frame.session ? sessions.get(frame.session) : null;
148
+ const { session: _routed, ...rest } = frame;
149
+ if (target) {
150
+ writeToSession(target, rest);
151
+ return;
152
+ }
153
+ if (frame.t === 'event') {
154
+ for (const link of sessions.values())
155
+ writeToSession(link, rest);
156
+ }
157
+ }
158
+ }
159
+ function writeToSession(link, frame) {
160
+ try {
161
+ link.socket.write(`${JSON.stringify(frame)}\n`);
162
+ }
163
+ catch {
164
+ }
165
+ }
166
+ function sendHello(link) {
167
+ writeToSession(link, { t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, browser: identity() });
168
+ }
169
+ function publishSessions() {
170
+ writeToExtension({
171
+ t: 'sessions',
172
+ sessions: [...sessions.values()].map((link) => ({
173
+ session: link.key,
174
+ label: link.record.label,
175
+ cwd: link.record.cwd,
176
+ })),
177
+ });
178
+ }
179
+ function connectToSession(record, socketPath) {
180
+ if (sessions.has(record.session))
181
+ return;
182
+ const socket = net.createConnection(socketPath);
183
+ socket.setNoDelay(true);
184
+ const link = { key: record.session, socket, record };
185
+ socket.on('connect', () => {
186
+ sessions.set(record.session, link);
187
+ sendHello(link);
188
+ publishSessions();
189
+ log(`attached to session ${record.session} (${record.cwd})`);
190
+ });
191
+ let buffer = '';
192
+ socket.on('data', (chunk) => {
193
+ buffer += chunk.toString('utf8');
194
+ let newline = buffer.indexOf('\n');
195
+ while (newline !== -1) {
196
+ const line = buffer.slice(0, newline);
197
+ buffer = buffer.slice(newline + 1);
198
+ newline = buffer.indexOf('\n');
199
+ if (!line.trim())
200
+ continue;
201
+ let frame;
202
+ try {
203
+ frame = JSON.parse(line);
204
+ }
205
+ catch {
206
+ continue;
207
+ }
208
+ writeToExtension({ ...frame, session: record.session });
209
+ }
210
+ });
211
+ const drop = () => {
212
+ if (sessions.get(record.session) === link) {
213
+ sessions.delete(record.session);
214
+ publishSessions();
215
+ }
216
+ socket.destroy();
217
+ };
218
+ socket.on('close', drop);
219
+ socket.on('error', () => {
220
+ drop();
221
+ });
222
+ }
223
+ function scanForSessions() {
224
+ let entries;
225
+ try {
226
+ entries = fs.readdirSync(runtimeDir);
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ for (const entry of entries) {
232
+ const sessionKey = sessionKeyFromRecordFile(entry);
233
+ if (!sessionKey)
234
+ continue;
235
+ const recordPath = path.join(runtimeDir, entry);
236
+ let record;
237
+ try {
238
+ record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
239
+ }
240
+ catch {
241
+ continue;
242
+ }
243
+ if (record.protocol !== BROWSER_PROTOCOL_VERSION)
244
+ continue;
245
+ try {
246
+ process.kill(record.pid, 0);
247
+ }
248
+ catch (error) {
249
+ if (error?.code !== 'EPERM') {
250
+ try {
251
+ fs.unlinkSync(recordPath);
252
+ if (socketIsFile())
253
+ fs.unlinkSync(sessionSocketPath(runtimeDir, sessionKey));
254
+ }
255
+ catch {
256
+ }
257
+ continue;
258
+ }
259
+ }
260
+ connectToSession(record, sessionSocketPath(runtimeDir, sessionKey));
261
+ }
262
+ }
263
+ setInterval(scanForSessions, SCAN_INTERVAL_MS);
264
+ scanForSessions();
265
+ log(`started for ${parent.browser} / ${parent.profile}`);
@@ -9,6 +9,16 @@ const PLAN_MODE_TOOL_NAMES = new Set([
9
9
  'run_command',
10
10
  'shell_job_output',
11
11
  'update_todos',
12
+ 'browser',
13
+ 'browser_tabs',
14
+ 'browser_read_page',
15
+ 'browser_get_page_text',
16
+ 'browser_find',
17
+ 'browser_console',
18
+ 'browser_downloads',
19
+ 'browser_network',
20
+ 'browser_screenshot',
21
+ 'browser_batch',
12
22
  ]);
13
23
  const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
14
24
  'pwd',
@@ -10,6 +10,7 @@ import { collectProjectOrientation } from '../project-orientation.js';
10
10
  import { describeSessionImageStore } from '../core/session-image-store.js';
11
11
  import { autoAttachImages } from '../core/image-path-extractor.js';
12
12
  import { formatTurnFailureMarker } from '../turn-failure-marker.js';
13
+ import { browserExtensionSeen, releaseBrowserAfterTurn } from '../browser/session-bridge.js';
13
14
  export class TurnCancelledError extends Error {
14
15
  name = 'TurnCancelledError';
15
16
  constructor(message = 'Turn cancelled.') {
@@ -549,7 +550,9 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
549
550
  projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
550
551
  sessionImageStore: describeSessionImageStore(session.env) ?? undefined,
551
552
  imageAttachments: imageAttachmentsForServer(requestImageAttachments),
552
- maxToolSteps: session.maxToolSteps,
553
+ ...(session.maxToolStepsWasExplicit || !browserExtensionSeen(session.env)
554
+ ? { maxToolSteps: session.maxToolSteps }
555
+ : {}),
553
556
  autoYes: session.autoYes,
554
557
  agentMode: session.agentMode,
555
558
  };
@@ -623,5 +626,6 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
623
626
  }
624
627
  finally {
625
628
  signal?.removeEventListener('abort', preserveOnAbort);
629
+ releaseBrowserAfterTurn();
626
630
  }
627
631
  }
@@ -0,0 +1,232 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { registerNativeHost } from './native-host.js';
6
+ import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, protocolMismatchMessage, sessionKeyFromRecordFile, sessionRecordPath, sessionSocketPath, socketIsFile, } from './protocol.js';
7
+ const DEFAULT_TIMEOUT_MS = 30000;
8
+ export class BrowserBridgeError extends Error {
9
+ code;
10
+ constructor(message, code = 'browser_error') {
11
+ super(message);
12
+ this.name = 'BrowserBridgeError';
13
+ this.code = code;
14
+ }
15
+ }
16
+ function safeUnlink(target) {
17
+ try {
18
+ fs.unlinkSync(target);
19
+ }
20
+ catch {
21
+ }
22
+ }
23
+ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env, }) {
24
+ const dir = browserRuntimeDir(env);
25
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ const socketPath = sessionSocketPath(dir, sessionKey);
27
+ const recordPath = sessionRecordPath(dir, sessionKey);
28
+ if (socketIsFile())
29
+ safeUnlink(socketPath);
30
+ const browsers = new Map();
31
+ const listeners = new Set();
32
+ let selected = null;
33
+ let closed = false;
34
+ const registration = registerNativeHost({ env });
35
+ const server = net.createServer((socket) => {
36
+ socket.setNoDelay(true);
37
+ let buffer = '';
38
+ let key = null;
39
+ socket.on('data', (chunk) => {
40
+ buffer += chunk.toString('utf8');
41
+ let newline = buffer.indexOf('\n');
42
+ while (newline !== -1) {
43
+ const line = buffer.slice(0, newline);
44
+ buffer = buffer.slice(newline + 1);
45
+ newline = buffer.indexOf('\n');
46
+ if (!line.trim())
47
+ continue;
48
+ let frame;
49
+ try {
50
+ frame = JSON.parse(line);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ if (frame.t === 'hello') {
56
+ if (frame.protocol !== BROWSER_PROTOCOL_VERSION) {
57
+ const message = protocolMismatchMessage(frame.protocol);
58
+ for (const listener of listeners)
59
+ listener('protocol_mismatch', { message });
60
+ socket.destroy();
61
+ return;
62
+ }
63
+ const announced = frame.browser?.key ?? crypto.randomUUID();
64
+ const existing = key ? browsers.get(key) : null;
65
+ if (existing) {
66
+ if (announced !== key) {
67
+ browsers.delete(key);
68
+ browsers.set(announced, existing);
69
+ if (selected === key)
70
+ selected = announced;
71
+ }
72
+ existing.identity = frame.browser;
73
+ existing.socket = socket;
74
+ }
75
+ else {
76
+ browsers.set(announced, {
77
+ identity: frame.browser,
78
+ socket,
79
+ pending: new Map(),
80
+ });
81
+ if (!selected)
82
+ selected = announced;
83
+ }
84
+ key = announced;
85
+ for (const listener of listeners) {
86
+ listener('connected', { browser: frame.browser });
87
+ }
88
+ continue;
89
+ }
90
+ const browser = key ? browsers.get(key) : null;
91
+ if (!browser)
92
+ continue;
93
+ if (frame.t === 'res') {
94
+ const pending = browser.pending.get(frame.id);
95
+ if (!pending)
96
+ continue;
97
+ browser.pending.delete(frame.id);
98
+ clearTimeout(pending.timer);
99
+ if (frame.ok)
100
+ pending.resolve(frame.result);
101
+ else
102
+ pending.reject(new BrowserBridgeError(frame.error ?? 'Browser operation failed.', frame.code ?? 'browser_error'));
103
+ continue;
104
+ }
105
+ if (frame.t === 'event') {
106
+ for (const listener of listeners)
107
+ listener(frame.event, frame.data ?? {});
108
+ }
109
+ }
110
+ });
111
+ const drop = () => {
112
+ if (!key)
113
+ return;
114
+ const browser = browsers.get(key);
115
+ browsers.delete(key);
116
+ if (selected === key)
117
+ selected = browsers.keys().next().value ?? null;
118
+ for (const pending of browser?.pending.values() ?? []) {
119
+ clearTimeout(pending.timer);
120
+ pending.reject(new BrowserBridgeError(`The browser disconnected while ${pending.op} was in flight, so its outcome is unknown. Check the page before doing anything that could repeat.`, 'disconnected'));
121
+ }
122
+ for (const listener of listeners)
123
+ listener('disconnected', { browser: key });
124
+ };
125
+ socket.on('close', drop);
126
+ socket.on('error', drop);
127
+ });
128
+ server.listen(socketPath, () => {
129
+ try {
130
+ fs.chmodSync(socketPath, 0o600);
131
+ }
132
+ catch {
133
+ }
134
+ const announcement = {
135
+ protocol: BROWSER_PROTOCOL_VERSION,
136
+ session: sessionKey,
137
+ pid: process.pid,
138
+ label,
139
+ cwd,
140
+ startedAt: new Date().toISOString(),
141
+ };
142
+ try {
143
+ fs.writeFileSync(recordPath, `${JSON.stringify(announcement)}\n`, { mode: 0o600 });
144
+ }
145
+ catch {
146
+ }
147
+ });
148
+ server.on('error', () => {
149
+ });
150
+ const close = () => {
151
+ if (closed)
152
+ return;
153
+ closed = true;
154
+ for (const browser of browsers.values())
155
+ browser.socket.destroy();
156
+ browsers.clear();
157
+ server.close();
158
+ safeUnlink(socketPath);
159
+ safeUnlink(recordPath);
160
+ };
161
+ process.once('exit', close);
162
+ return {
163
+ sessionKey,
164
+ status() {
165
+ return {
166
+ connected: browsers.size > 0,
167
+ browsers: [...browsers.values()].map((browser) => browser.identity),
168
+ selected,
169
+ socketPath,
170
+ registrationErrors: registration.errors,
171
+ };
172
+ },
173
+ selectBrowser(key) {
174
+ if (browsers.has(key))
175
+ selected = key;
176
+ },
177
+ onEvent(listener) {
178
+ listeners.add(listener);
179
+ return () => listeners.delete(listener);
180
+ },
181
+ request(op, args = {}, options = {}) {
182
+ const target = selected ? browsers.get(selected) : null;
183
+ if (!target) {
184
+ return Promise.reject(new BrowserBridgeError('No browser is connected to this session.', 'not_connected'));
185
+ }
186
+ const id = crypto.randomUUID();
187
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
188
+ return new Promise((resolve, reject) => {
189
+ const timer = setTimeout(() => {
190
+ target.pending.delete(id);
191
+ reject(new BrowserBridgeError(`The browser did not answer ${op} within ${Math.round(timeoutMs / 1000)}s, so its outcome is unknown. Look at the page before repeating anything that changes state.`, 'timeout'));
192
+ }, timeoutMs);
193
+ target.pending.set(id, { resolve, reject, timer, op });
194
+ target.socket.write(`${JSON.stringify({ t: 'req', id, op, args })}\n`, (error) => {
195
+ if (!error)
196
+ return;
197
+ target.pending.delete(id);
198
+ clearTimeout(timer);
199
+ reject(new BrowserBridgeError(`Could not reach the browser: ${error.message}`, 'write_failed'));
200
+ });
201
+ });
202
+ },
203
+ close,
204
+ };
205
+ }
206
+ export function pruneStaleSessions(env = process.env) {
207
+ const dir = browserRuntimeDir(env);
208
+ let entries;
209
+ try {
210
+ entries = fs.readdirSync(dir);
211
+ }
212
+ catch {
213
+ return;
214
+ }
215
+ for (const entry of entries) {
216
+ const key = sessionKeyFromRecordFile(entry);
217
+ if (!key)
218
+ continue;
219
+ const file = path.join(dir, entry);
220
+ try {
221
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
222
+ process.kill(record.pid, 0);
223
+ }
224
+ catch (error) {
225
+ if (error?.code === 'EPERM')
226
+ continue;
227
+ safeUnlink(file);
228
+ if (socketIsFile())
229
+ safeUnlink(sessionSocketPath(dir, key));
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,42 @@
1
+ export const MAX_FRAME_BYTES = 900 * 1024;
2
+ export const FRAGMENT_BYTES = 512 * 1024;
3
+ export function encodeFrame(payload) {
4
+ const header = Buffer.alloc(4);
5
+ header.writeUInt32LE(payload.length, 0);
6
+ return Buffer.concat([header, payload]);
7
+ }
8
+ export function fragmentPayload(payload, id) {
9
+ const total = Math.ceil(payload.length / FRAGMENT_BYTES);
10
+ const frames = [];
11
+ for (let part = 0; part < total; part += 1) {
12
+ frames.push({
13
+ t: 'fragment',
14
+ id,
15
+ part,
16
+ total,
17
+ data: payload.subarray(part * FRAGMENT_BYTES, (part + 1) * FRAGMENT_BYTES).toString('base64'),
18
+ });
19
+ }
20
+ return frames;
21
+ }
22
+ export function decodeFrames(inbound) {
23
+ const frames = [];
24
+ let rest = inbound;
25
+ let unparseable = 0;
26
+ for (;;) {
27
+ if (rest.length < 4)
28
+ break;
29
+ const length = rest.readUInt32LE(0);
30
+ if (rest.length < 4 + length)
31
+ break;
32
+ const body = rest.subarray(4, 4 + length);
33
+ rest = rest.subarray(4 + length);
34
+ try {
35
+ frames.push(JSON.parse(body.toString('utf8')));
36
+ }
37
+ catch {
38
+ unparseable += 1;
39
+ }
40
+ }
41
+ return { frames, rest, unparseable };
42
+ }