@thegitai/cli 1.0.0-preview.46 → 1.0.0-preview.48

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.
@@ -13,6 +13,13 @@ export class BrowserBridgeError extends Error {
13
13
  this.code = code;
14
14
  }
15
15
  }
16
+ function rejectDisconnectedRequests(browser) {
17
+ for (const pending of browser.pending.values()) {
18
+ clearTimeout(pending.timer);
19
+ 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'));
20
+ }
21
+ browser.pending.clear();
22
+ }
16
23
  function safeUnlink(target) {
17
24
  try {
18
25
  fs.unlinkSync(target);
@@ -28,15 +35,20 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
28
35
  if (socketIsFile())
29
36
  safeUnlink(socketPath);
30
37
  const browsers = new Map();
38
+ const sockets = new Set();
31
39
  const listeners = new Set();
32
40
  let selected = null;
41
+ let everConnected = false;
33
42
  let closed = false;
34
43
  const registration = registerNativeHost({ env });
35
44
  const server = net.createServer((socket) => {
36
45
  socket.setNoDelay(true);
46
+ sockets.add(socket);
37
47
  let buffer = '';
38
48
  let key = null;
39
49
  socket.on('data', (chunk) => {
50
+ if (socket.destroyed)
51
+ return;
40
52
  buffer += chunk.toString('utf8');
41
53
  let newline = buffer.indexOf('\n');
42
54
  while (newline !== -1) {
@@ -62,7 +74,12 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
62
74
  }
63
75
  const announced = frame.browser?.key ?? crypto.randomUUID();
64
76
  const existing = key ? browsers.get(key) : null;
65
- if (existing) {
77
+ const displaced = browsers.get(announced);
78
+ if (displaced && displaced.socket !== socket) {
79
+ displaced.socket.destroy();
80
+ rejectDisconnectedRequests(displaced);
81
+ }
82
+ if (existing?.socket === socket) {
66
83
  if (announced !== key) {
67
84
  browsers.delete(key);
68
85
  browsers.set(announced, existing);
@@ -82,13 +99,14 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
82
99
  selected = announced;
83
100
  }
84
101
  key = announced;
102
+ everConnected = true;
85
103
  for (const listener of listeners) {
86
104
  listener('connected', { browser: frame.browser });
87
105
  }
88
106
  continue;
89
107
  }
90
108
  const browser = key ? browsers.get(key) : null;
91
- if (!browser)
109
+ if (!browser || browser.socket !== socket)
92
110
  continue;
93
111
  if (frame.t === 'res') {
94
112
  const pending = browser.pending.get(frame.id);
@@ -109,16 +127,16 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
109
127
  }
110
128
  });
111
129
  const drop = () => {
130
+ sockets.delete(socket);
112
131
  if (!key)
113
132
  return;
114
133
  const browser = browsers.get(key);
134
+ if (!browser || browser.socket !== socket)
135
+ return;
115
136
  browsers.delete(key);
116
137
  if (selected === key)
117
138
  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
- }
139
+ rejectDisconnectedRequests(browser);
122
140
  for (const listener of listeners)
123
141
  listener('disconnected', { browser: key });
124
142
  };
@@ -152,7 +170,10 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
152
170
  return;
153
171
  closed = true;
154
172
  for (const browser of browsers.values())
155
- browser.socket.destroy();
173
+ rejectDisconnectedRequests(browser);
174
+ for (const socket of sockets)
175
+ socket.destroy();
176
+ sockets.clear();
156
177
  browsers.clear();
157
178
  server.close();
158
179
  safeUnlink(socketPath);
@@ -164,12 +185,21 @@ export function startBrowserBridge({ sessionKey, label, cwd, env = process.env,
164
185
  status() {
165
186
  return {
166
187
  connected: browsers.size > 0,
188
+ everConnected,
167
189
  browsers: [...browsers.values()].map((browser) => browser.identity),
168
190
  selected,
169
191
  socketPath,
170
192
  registrationErrors: registration.errors,
171
193
  };
172
194
  },
195
+ resync() {
196
+ if (closed || browsers.size > 0 || sockets.size === 0)
197
+ return false;
198
+ for (const socket of sockets)
199
+ socket.destroy();
200
+ sockets.clear();
201
+ return true;
202
+ },
173
203
  selectBrowser(key) {
174
204
  if (browsers.has(key))
175
205
  selected = key;
@@ -201,9 +201,19 @@ export function chromiumProcessRunning(platform = process.platform) {
201
201
  timeout: 2000,
202
202
  stdio: ['ignore', 'pipe', 'ignore'],
203
203
  });
204
- return /(google[- ]?chrome|chromium|brave|msedge|microsoft edge|vivaldi)/i.test(output);
204
+ return output.split('\n').some((line) => isBrowserProcessName(line));
205
205
  }
206
206
  catch {
207
207
  return false;
208
208
  }
209
209
  }
210
+ const BROWSER_PROCESS_NAME = /^(google[ -]?chrome|chrome|chromium|brave|msedge|microsoft[ -]?edge|vivaldi)/i;
211
+ const NOT_A_BROWSER_PROCESS = /crashpad|crashhandler|native|sandbox|driver|updater?\b/i;
212
+ export function isBrowserProcessName(psLine) {
213
+ const name = psLine.trim().split('/').pop()?.trim() ?? '';
214
+ if (!name)
215
+ return false;
216
+ if (NOT_A_BROWSER_PROCESS.test(name))
217
+ return false;
218
+ return BROWSER_PROCESS_NAME.test(name);
219
+ }
@@ -86,19 +86,45 @@ export function browserAvailability(options) {
86
86
  if (status.connected) {
87
87
  return {
88
88
  connected: true,
89
+ everConnected: true,
89
90
  browsers: status.browsers,
90
91
  selected: status.selected,
91
92
  };
92
93
  }
93
- const hint = status.registrationErrors.length
94
- ? `TheGitAI could not register its browser bridge with Chrome: ${status.registrationErrors[0]}`
95
- : chromiumProcessRunning()
96
- ? 'Chrome is running but the TheGitAI extension has not connected. Install or enable the extension, then reload it from chrome://extensions.'
97
- : 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension installed.';
94
+ const hint = connectionHint({
95
+ registrationError: status.registrationErrors[0] ?? null,
96
+ everConnected: status.everConnected,
97
+ chromeRunning: () => chromiumProcessRunning(),
98
+ extensionSeen: () => browserExtensionSeen(options.env),
99
+ });
98
100
  return {
99
101
  connected: false,
102
+ everConnected: status.everConnected,
100
103
  browsers: [],
101
104
  selected: null,
102
105
  hint,
103
106
  };
104
107
  }
108
+ export function connectionHint(state) {
109
+ if (state.registrationError) {
110
+ return `TheGitAI could not register its browser bridge with Chrome: ${state.registrationError}`;
111
+ }
112
+ if (state.everConnected) {
113
+ return 'The browser was connected to this session but is disconnected now. Check Chrome and the connection status shown by the TheGitAI extension.';
114
+ }
115
+ if (!state.chromeRunning()) {
116
+ return 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension enabled and check its connection status.';
117
+ }
118
+ if (state.extensionSeen()) {
119
+ return 'The TheGitAI extension has connected on this machine before, but has not connected to this session yet. Check the connection status shown by the extension.';
120
+ }
121
+ return 'Chrome is running, but no browser connection reached this session. Check that the TheGitAI extension is installed and enabled, and inspect its connection status.';
122
+ }
123
+ export function resyncBrowserBridge(options) {
124
+ try {
125
+ return ensureBrowserBridge(options).resync();
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
@@ -3,11 +3,12 @@ import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { BrowserBridgeError } from '../browser/bridge.js';
5
5
  import { storeSessionImageBytes } from '../core/session-image-store.js';
6
- import { browserAvailability, drainBrowserEvents, ensureBrowserBridge, } from '../browser/session-bridge.js';
6
+ import { browserAvailability, drainBrowserEvents, ensureBrowserBridge, resyncBrowserBridge, } from '../browser/session-bridge.js';
7
7
  import { ensurePermission, originForUrl } from '../permissions.js';
8
8
  const READ_TIMEOUT_MS = 20000;
9
9
  const NAVIGATE_TIMEOUT_MS = 45000;
10
10
  const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
11
+ const DISCOVERY_TIMEOUT_MS = 2500;
11
12
  export const READ_ONLY_BROWSER_TOOLS = new Set([
12
13
  'browser',
13
14
  'browser_tabs',
@@ -31,25 +32,33 @@ function missing(argument, tool) {
31
32
  },
32
33
  };
33
34
  }
34
- function notConnected(context) {
35
- const availability = browserAvailability({
36
- cwd: context.rootDir,
37
- env: context.env,
38
- });
35
+ async function waitForBrowser(options, timeoutMs) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ const bridge = ensureBrowserBridge(options);
38
+ while (!bridge.status().connected && Date.now() < deadline) {
39
+ await new Promise((resolve) => setTimeout(resolve, 100));
40
+ }
41
+ return browserAvailability(options);
42
+ }
43
+ async function notConnected(context) {
44
+ const options = { cwd: context.rootDir, env: context.env };
45
+ const status = ensureBrowserBridge(options).status();
46
+ if (status.connected)
47
+ return { ok: true };
48
+ if (status.everConnected)
49
+ resyncBrowserBridge(options);
50
+ const availability = await waitForBrowser(options, DISCOVERY_TIMEOUT_MS);
39
51
  if (availability.connected)
40
52
  return { ok: true };
41
53
  return {
42
54
  ok: false,
55
+ connected: false,
56
+ browserErrorCode: 'not_connected',
43
57
  error: `TheGitAI is not connected to a browser. ${availability.hint ?? ''}`.trim(),
44
58
  failureCategory: 'external_service',
45
- failureDetails: {
46
- category: 'external_service',
47
- tool: 'browser',
48
- action: 'Report the connection instructions. The extension is a separate installation. Do not retry in a loop or invent a download URL.',
49
- },
50
59
  };
51
60
  }
52
- function bridgeFailure(error, tool) {
61
+ function bridgeFailure(error) {
53
62
  const code = error instanceof BrowserBridgeError ? error.code : 'browser_error';
54
63
  const message = String(error?.message ?? error);
55
64
  return {
@@ -57,15 +66,6 @@ function bridgeFailure(error, tool) {
57
66
  error: message,
58
67
  failureCategory: code === 'not_connected' ? 'external_service' : 'tool_exception',
59
68
  browserErrorCode: code,
60
- failureDetails: {
61
- category: code === 'not_connected' ? 'external_service' : 'tool_exception',
62
- tool,
63
- action: code === 'timeout' || code === 'disconnected'
64
- ? 'The outcome is unknown. Read the page before repeating anything that submits, sends, buys, or deletes.'
65
- : code === 'stale_refs' || code === 'unknown_ref'
66
- ? 'Re-read the page and use the references from that read.'
67
- : 'Report what failed. Do not retry the same call more than once.',
68
- },
69
69
  };
70
70
  }
71
71
  async function call(context, op, args = {}, timeoutMs = READ_TIMEOUT_MS) {
@@ -106,10 +106,10 @@ function attachEvents(result) {
106
106
  };
107
107
  }
108
108
  export async function browserGateway(context) {
109
+ const guard = await notConnected(context);
110
+ if (guard.ok === false)
111
+ return guard;
109
112
  const availability = browserAvailability({ cwd: context.rootDir, env: context.env });
110
- if (!availability.connected) {
111
- return notConnected(context);
112
- }
113
113
  const platform = availability.browsers[0]?.platform ?? process.platform;
114
114
  return {
115
115
  ok: true,
@@ -125,7 +125,7 @@ export async function browserGateway(context) {
125
125
  };
126
126
  }
127
127
  export async function browserTabs(context, args) {
128
- const guard = notConnected(context);
128
+ const guard = await notConnected(context);
129
129
  if (guard.ok === false)
130
130
  return guard;
131
131
  try {
@@ -135,11 +135,11 @@ export async function browserTabs(context, args) {
135
135
  return attachEvents({ ok: true, ...result });
136
136
  }
137
137
  catch (error) {
138
- return bridgeFailure(error, 'browser_tabs');
138
+ return bridgeFailure(error);
139
139
  }
140
140
  }
141
141
  export async function browserTabNew(context) {
142
- const guard = notConnected(context);
142
+ const guard = await notConnected(context);
143
143
  if (guard.ok === false)
144
144
  return guard;
145
145
  try {
@@ -147,11 +147,11 @@ export async function browserTabNew(context) {
147
147
  return attachEvents({ ok: true, ...result });
148
148
  }
149
149
  catch (error) {
150
- return bridgeFailure(error, 'browser_tab_new');
150
+ return bridgeFailure(error);
151
151
  }
152
152
  }
153
153
  export async function browserTabClose(context, args) {
154
- const guard = notConnected(context);
154
+ const guard = await notConnected(context);
155
155
  if (guard.ok === false)
156
156
  return guard;
157
157
  const tabId = Number(args.tabId ?? args.tab_id);
@@ -162,11 +162,11 @@ export async function browserTabClose(context, args) {
162
162
  return attachEvents({ ok: true, ...result });
163
163
  }
164
164
  catch (error) {
165
- return bridgeFailure(error, 'browser_tab_close');
165
+ return bridgeFailure(error);
166
166
  }
167
167
  }
168
168
  export async function browserNavigate(context, args) {
169
- const guard = notConnected(context);
169
+ const guard = await notConnected(context);
170
170
  if (guard.ok === false)
171
171
  return guard;
172
172
  const url = String(args.url ?? '').trim();
@@ -195,11 +195,11 @@ export async function browserNavigate(context, args) {
195
195
  return attachEvents({ ok: true, ...result });
196
196
  }
197
197
  catch (error) {
198
- return bridgeFailure(error, 'browser_navigate');
198
+ return bridgeFailure(error);
199
199
  }
200
200
  }
201
201
  async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ_TIMEOUT_MS, }) {
202
- const guard = notConnected(context);
202
+ const guard = await notConnected(context);
203
203
  if (guard.ok === false)
204
204
  return guard;
205
205
  const tabId = Number(args.tabId ?? args.tab_id);
@@ -232,7 +232,7 @@ async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ
232
232
  return attachEvents({ ok: true, tabId, url: tab.url, ...result });
233
233
  }
234
234
  catch (error) {
235
- return bridgeFailure(error, tool);
235
+ return bridgeFailure(error);
236
236
  }
237
237
  }
238
238
  export function browserReadPage(context, args) {
@@ -389,7 +389,7 @@ export async function browserDownloads(context, args) {
389
389
  error: `Plan mode blocked browser_downloads ${action}. Listing downloads is allowed; saving or cancelling one changes the user's disk. Switch to Default mode to decide on a download.`,
390
390
  };
391
391
  }
392
- const guard = notConnected(context);
392
+ const guard = await notConnected(context);
393
393
  if (guard.ok === false)
394
394
  return guard;
395
395
  if (action === 'list') {
@@ -459,7 +459,7 @@ export function browserResize(context, args) {
459
459
  });
460
460
  }
461
461
  export async function browserFileUpload(context, args) {
462
- const guard = notConnected(context);
462
+ const guard = await notConnected(context);
463
463
  if (guard.ok === false)
464
464
  return guard;
465
465
  const ref = String(args.ref ?? '').trim();
@@ -540,7 +540,7 @@ export async function browserFileUpload(context, args) {
540
540
  return attachEvents({ ok: true, ...result, files: files.map((file) => file.name) });
541
541
  }
542
542
  catch (error) {
543
- return bridgeFailure(error, 'browser_file_upload');
543
+ return bridgeFailure(error);
544
544
  }
545
545
  }
546
546
  function guessMimeType(file) {
@@ -580,7 +580,7 @@ const BATCH_TOOLS = {
580
580
  browser_downloads: browserDownloads,
581
581
  };
582
582
  export async function browserBatch(context, args) {
583
- const guard = notConnected(context);
583
+ const guard = await notConnected(context);
584
584
  if (guard.ok === false)
585
585
  return guard;
586
586
  const actions = Array.isArray(args.actions) ? args.actions : [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.46",
3
+ "version": "1.0.0-preview.48",
4
4
  "description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
5
5
  "keywords": [
6
6
  "agentic-ai",
@@ -44,11 +44,11 @@
44
44
  "@lydell/node-pty-linux-x64": "1.1.0",
45
45
  "@lydell/node-pty-win32-arm64": "1.1.0",
46
46
  "@lydell/node-pty-win32-x64": "1.1.0",
47
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.46",
48
- "@thegitai/tui-darwin-x64": "1.0.0-preview.46",
49
- "@thegitai/tui-linux-arm64": "1.0.0-preview.46",
50
- "@thegitai/tui-linux-x64": "1.0.0-preview.46",
51
- "@thegitai/tui-win32-x64": "1.0.0-preview.46",
47
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.48",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.48",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.48",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.48",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.48",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {