@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.41

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 (68) hide show
  1. package/README.md +49 -4
  2. package/dist/bin/ai.js +119 -111
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +11 -6
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +152 -37
  7. package/dist/src/api/chat.js +286 -44
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +127 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/background-jobs.js +4 -2
  13. package/dist/src/browser/bridge.js +232 -0
  14. package/dist/src/browser/framing.js +42 -0
  15. package/dist/src/browser/native-host.js +209 -0
  16. package/dist/src/browser/protocol.js +46 -0
  17. package/dist/src/browser/session-bridge.js +104 -0
  18. package/dist/src/cli-args.js +19 -5
  19. package/dist/src/client-environment.js +2 -0
  20. package/dist/src/core/clipboard.js +7 -13
  21. package/dist/src/core/image-limits.js +56 -0
  22. package/dist/src/core/image-path-extractor.js +70 -3
  23. package/dist/src/core/session-image-store.js +246 -0
  24. package/dist/src/edit-journal.js +33 -2
  25. package/dist/src/executor.js +25 -3
  26. package/dist/src/help-text.js +58 -17
  27. package/dist/src/patcher.js +43 -4
  28. package/dist/src/permissions.js +288 -0
  29. package/dist/src/session-safety.js +0 -12
  30. package/dist/src/session-store.js +121 -20
  31. package/dist/src/session.js +19 -4
  32. package/dist/src/signin.js +58 -0
  33. package/dist/src/tool-executor.js +12 -46
  34. package/dist/src/tools/browser.js +628 -0
  35. package/dist/src/tools/delete-file.js +20 -4
  36. package/dist/src/tools/index.js +32 -10
  37. package/dist/src/tools/patch-file.js +12 -26
  38. package/dist/src/tools/read-image-file.js +85 -0
  39. package/dist/src/tools/replace-document-text.js +28 -18
  40. package/dist/src/tools/restore-checkpoint.js +0 -1
  41. package/dist/src/tools/run-command.js +14 -71
  42. package/dist/src/tools/run-node-script.js +12 -81
  43. package/dist/src/tools/save-generated-image.js +120 -0
  44. package/dist/src/tools/str-replace.js +12 -26
  45. package/dist/src/tools/undo-edit.js +1 -6
  46. package/dist/src/tools/write-file.js +67 -11
  47. package/dist/src/ui/prompt-history-store.js +1 -1
  48. package/dist/src/ui/repl.js +802 -195
  49. package/dist/src/ui/tui/bridge.js +10 -0
  50. package/dist/src/ui/tui/build-frame.js +517 -165
  51. package/dist/src/ui/tui/composer-layout.js +76 -0
  52. package/dist/src/ui/tui/markdown-render.js +81 -73
  53. package/dist/src/ui/tui/shell-input.js +227 -67
  54. package/dist/src/ui/tui/terminal-theme.js +28 -0
  55. package/dist/src/ui/tui/terminal-title.js +3 -0
  56. package/dist/src/ui/tui/terminal-writes.js +48 -0
  57. package/dist/src/ui/tui/text.js +158 -4
  58. package/dist/src/ui/tui/user-input.js +568 -0
  59. package/dist/src/utils.js +9 -0
  60. package/package.json +20 -8
  61. package/dist/src/markdown-renderer.js +0 -112
  62. package/dist/src/project-index.js +0 -221
  63. package/dist/src/tools/code-intel.js +0 -472
  64. package/dist/src/tools/find-symbol.js +0 -70
  65. package/dist/src/tools/hover-symbol.js +0 -95
  66. package/dist/src/tools/list-symbols.js +0 -55
  67. package/dist/src/tools/search-code.js +0 -37
  68. package/dist/src/tools/signature-help.js +0 -118
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TheGitAI — AI coding agent for your terminal
2
2
 
3
- TheGitAI is an AI coding agent for your terminal. It indexes your repository,
3
+ TheGitAI is an AI coding agent for your terminal. It reads and searches your repository,
4
4
  writes and edits files, runs commands, and builds features with you.
5
5
 
6
6
  Talk to your repo in plain English. TheGitAI can keep long-running processes
@@ -12,14 +12,14 @@ going while you work, all with your approval.
12
12
  npm i -g @thegitai/cli
13
13
  ```
14
14
 
15
- Requires Node.js 24 or newer.
15
+ Supports Node.js 22 and newer. Node.js 24 is recommended.
16
16
 
17
17
  ## Usage
18
18
 
19
19
  ```text
20
- ai start an interactive session in the current repo
20
+ ai sign in if needed, then start a session in the current repo
21
21
  ai "<request>" start a session with <request> as the first message
22
- ai login sign in via your browser (--no-browser for SSH/headless)
22
+ ai login the same as `ai`; kept for muscle memory
23
23
  ai whoami show the signed-in account
24
24
  ai --usage show account usage and reset times
25
25
  ai logout sign out
@@ -34,6 +34,51 @@ always be viewed or resumed from that computer. Continuing it through the
34
34
  service requires the TheGitAI account used for that session. Local sessions can
35
35
  also be listed while signed out or offline.
36
36
 
37
+ Signing in opens your browser. The same screen also prints a URL you can open on
38
+ any other device and a box for an authorization code, so SSH and headless
39
+ machines need no separate command: open the URL wherever you have a browser,
40
+ choose "On another computer" on that page, and paste the code it shows.
41
+
42
+ CLI login tokens use a rolling 48-hour inactivity timeout. If one expires during
43
+ any server request, the CLI removes the expired credential and signs you in
44
+ again on the next run.
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
+
65
+ ## Structured questions
66
+
67
+
68
+ The agent can pause its current turn to ask up to four related questions in one
69
+ form. Use **↑ / ↓**, the displayed option number, or **Enter** to choose, and
70
+ **← / →** to move between questions. **Something else / add details** is the
71
+ numbered final option; focus it and type, paste, or press **Enter** to add a
72
+ single-line note. On single-select questions it is mutually exclusive with the
73
+ listed choices, and **↑ / ↓** leaves its editor when the note is empty.
74
+ Multi-select questions toggle choices. The last question has an explicit
75
+ **Submit answers** row. **Esc** closes an open note first and otherwise cancels
76
+ the form; **Ctrl+C** cancels the whole turn.
77
+
78
+ Answers return to the same turn, and completed question/answer records remain
79
+ readable when the session is resumed. Default, Auto-Accept, and Plan modes all
80
+ support the form; Auto-Accept does not choose answers for you.
81
+
37
82
  ## Visible to-do list
38
83
 
39
84
  For larger multi-step tasks, the agent keeps a compact to-do list on screen so
package/dist/bin/ai.js CHANGED
@@ -1,68 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from '../src/colors.js';
3
- import { stdin as input, stdout as output } from 'node:process';
4
- import readline from 'node:readline/promises';
5
3
  import { ServerApi } from '../src/api/index.js';
6
- import { loginViaBrowser } from '../src/api/browser-login.js';
7
- import { isTransientNetworkError } from '../src/api/http.js';
4
+ import { DEFAULT_THEGITAI_HOST } from '../src/api/default-host.js';
5
+ import { isSignInCancelled } from '../src/api/browser-login.js';
6
+ import { runSignIn } from '../src/signin.js';
7
+ import { STARTUP_RETRY_BUDGET, authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
8
8
  import { formatCliHelpText } from '../src/help-text.js';
9
- import { createIndex } from '../src/project-index.js';
10
- import { createSession } from '../src/session.js';
11
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../src/session-store.js';
9
+ import { createSession, disposeSession } from '../src/session.js';
10
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../src/session-store.js';
12
11
  import { runClientInteractive } from '../src/ui/repl.js';
12
+ import { startBrowserDiscoveryInBackground } from '../src/browser/session-bridge.js';
13
13
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
14
14
  import { formatSessionExitNotice } from '../src/session-exit.js';
15
15
  import { formatUsageText } from '../src/usage.js';
16
16
  import { formatVersionLine } from '../src/version.js';
17
17
  import { parseArgs } from '../src/cli-args.js';
18
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
19
18
  const { auth, chat, models, sessions } = ServerApi;
20
19
  function printUsage() {
21
20
  console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
22
21
  }
23
- async function promptText(question, fallback = null) {
24
- const rl = readline.createInterface({ input, output });
25
- try {
26
- const suffix = fallback ? ` (${fallback})` : '';
27
- const answer = await rl.question(`${question}${suffix}: `);
28
- return answer.trim() || fallback || '';
29
- }
30
- finally {
31
- rl.close();
22
+ function unreachableReason(error) {
23
+ const err = error;
24
+ const code = err?.code ?? err?.cause?.code;
25
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
26
+ return 'network timeout';
32
27
  }
28
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
29
+ return 'DNS lookup failed';
30
+ if (code === 'ECONNREFUSED')
31
+ return 'connection refused';
32
+ if (code === 'ECONNRESET' || code === 'EPIPE')
33
+ return 'connection reset';
34
+ return err?.message ? String(err.message) : 'network error';
33
35
  }
34
36
  function appendPromptHistory(prompt, env = process.env) {
35
37
  appendPromptToFile(prompt, env);
36
38
  }
37
- async function runAuthCommand(command, args) {
38
- if (command === 'login') {
39
- const serverUrl = DEFAULT_SERVER_URL;
40
- const noBrowser = args.includes('--no-browser');
41
- console.log(chalk.dim(noBrowser
42
- ? 'Sign in on the website, then paste the authorization code here.'
43
- : 'Opening your browser to sign in…'));
44
- const result = await loginViaBrowser({
45
- serverUrl,
46
- noBrowser,
47
- onUrl: (url) => {
48
- console.log(chalk.dim(noBrowser ? 'Open this URL to sign in:' : 'If your browser did not open, visit:'));
49
- console.log(` ${url}`);
50
- },
51
- onWaiting: () => console.log(chalk.dim('Waiting for you to finish signing in…')),
52
- promptCode: noBrowser
53
- ? () => promptText('Paste the authorization code')
54
- : undefined,
55
- deviceName: process.env.THEGITAI_DEVICE_NAME?.trim() || undefined,
56
- });
57
- auth.writeCliAuthConfig(result);
58
- console.log(chalk.green(`✓ Logged in as ${result.customer.email}`));
59
- console.log(chalk.dim(`Server: ${result.serverUrl}`));
60
- console.log(chalk.dim('You can close the browser tab. Run `ai` in a repo to start.'));
61
- return;
62
- }
39
+ async function runAuthCommand(command) {
63
40
  const config = auth.readCliAuthConfig();
64
41
  if (!config) {
65
- throw new Error('Not logged in. Run `ai login` first.');
42
+ if (command === 'logout') {
43
+ console.log(chalk.green('Already signed out.'));
44
+ return;
45
+ }
46
+ throw new Error('Not signed in. Run `ai` to sign in.');
66
47
  }
67
48
  if (command === 'whoami') {
68
49
  const customer = await auth.fetchWhoami({ config });
@@ -70,19 +51,25 @@ async function runAuthCommand(command, args) {
70
51
  return;
71
52
  }
72
53
  if (command === 'logout') {
73
- await auth.logoutFromServer({ config });
54
+ try {
55
+ await auth.logoutFromServer({ config });
56
+ }
57
+ catch {
58
+ }
74
59
  auth.clearCliAuthConfig();
75
60
  console.log(chalk.green('Logged out.'));
76
61
  return;
77
62
  }
78
63
  throw new Error(`Unknown auth command: ${command}`);
79
64
  }
80
- function requireCliAuthConfig() {
65
+ async function ensureCliAuthConfig() {
81
66
  const config = auth.readCliAuthConfig();
82
- if (!config) {
83
- throw new Error('Not logged in. Run `ai login` first.');
67
+ if (config)
68
+ return config;
69
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
70
+ throw new Error('Not signed in. Run `ai login` on a terminal to sign in.');
84
71
  }
85
- return config;
72
+ return await runSignIn();
86
73
  }
87
74
  function formatSessionName(name) {
88
75
  return name ? `"${name}"` : '(unnamed)';
@@ -116,6 +103,8 @@ function modelLabel(serverModels, modelId) {
116
103
  `Model ${modelId}`);
117
104
  }
118
105
  async function saveSessionBoth({ session, serverSessionClient, }) {
106
+ if (!sessionHasUserMessage(session))
107
+ return;
119
108
  saveSessionState(session);
120
109
  try {
121
110
  await serverSessionClient.save(session);
@@ -138,7 +127,7 @@ function requireInteractiveTerminal() {
138
127
  return true;
139
128
  }
140
129
  export async function main() {
141
- const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
130
+ const { autoYes, help, version, usage, command, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
142
131
  if (version) {
143
132
  console.log(formatVersionLine());
144
133
  return;
@@ -153,18 +142,18 @@ export async function main() {
153
142
  process.exitCode = 2;
154
143
  return;
155
144
  }
156
- if (command) {
157
- await runAuthCommand(command, commandArgs);
145
+ if (command && command !== 'login') {
146
+ await runAuthCommand(command);
158
147
  return;
159
148
  }
160
149
  if (usage) {
161
- const authConfig = requireCliAuthConfig();
150
+ const authConfig = await ensureCliAuthConfig();
162
151
  console.log(formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })));
163
152
  return;
164
153
  }
165
154
  const rootDir = process.cwd();
166
155
  if (listSessions) {
167
- const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_SERVER_URL;
156
+ const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_THEGITAI_HOST;
168
157
  printSessionList(rootDir, listSessionMetadata(rootDir), models.selectCacheForServer(models.readCachedServerModels(), activeServerUrl));
169
158
  return;
170
159
  }
@@ -179,44 +168,54 @@ export async function main() {
179
168
  if (!requireInteractiveTerminal()) {
180
169
  return;
181
170
  }
182
- const authConfig = requireCliAuthConfig();
171
+ const authConfig = await ensureCliAuthConfig();
183
172
  const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
184
173
  const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
174
+ const [modelsOutcome, whoamiOutcome] = await Promise.allSettled([
175
+ models.fetchServerModels({
176
+ config: authConfig,
177
+ budget: STARTUP_RETRY_BUDGET,
178
+ }),
179
+ auth.fetchWhoamiResponse({
180
+ config: authConfig,
181
+ budget: STARTUP_RETRY_BUDGET,
182
+ }),
183
+ ]);
185
184
  let offlineNotice = null;
186
185
  let serverModels;
187
- try {
188
- serverModels = await models.fetchServerModels({ config: authConfig });
186
+ if (modelsOutcome.status === 'fulfilled') {
187
+ serverModels = modelsOutcome.value;
189
188
  }
190
- catch (error) {
191
- if (isTransientNetworkError(error) && cachedModels?.models.length) {
192
- serverModels = { models: cachedModels.models };
193
- offlineNotice = error?.message ? String(error.message) : 'network error';
194
- }
195
- else {
196
- throw error;
197
- }
189
+ else if (isTransientNetworkError(modelsOutcome.reason) &&
190
+ cachedModels?.models.length) {
191
+ serverModels = { models: cachedModels.models };
192
+ offlineNotice = unreachableReason(modelsOutcome.reason);
193
+ }
194
+ else if (isTransientNetworkError(modelsOutcome.reason)) {
195
+ throw new Error(`Couldn't reach TheGitAI to load your models (${unreachableReason(modelsOutcome.reason)}).\nCheck your internet connection and run \`ai\` again.`);
196
+ }
197
+ else {
198
+ throw modelsOutcome.reason;
198
199
  }
199
200
  let whoami;
200
- try {
201
- whoami = await auth.fetchWhoamiResponse({ config: authConfig });
201
+ if (whoamiOutcome.status === 'fulfilled') {
202
+ whoami = whoamiOutcome.value;
202
203
  }
203
- catch (error) {
204
- if (isTransientNetworkError(error)) {
205
- whoami = {
206
- customer: {
207
- id: '',
208
- uuid: '',
209
- email: authConfig.email,
210
- customer_type: authConfig.customerType ?? 'USER',
211
- scopes: [],
212
- },
213
- debugUi: { showSessionId: false },
214
- };
215
- offlineNotice ??= error?.message ? String(error.message) : 'network error';
216
- }
217
- else {
218
- throw error;
219
- }
204
+ else if (isTransientNetworkError(whoamiOutcome.reason)) {
205
+ whoami = {
206
+ customer: {
207
+ id: '',
208
+ uuid: '',
209
+ email: authConfig.email,
210
+ customer_type: authConfig.customerType ?? 'USER',
211
+ scopes: [],
212
+ },
213
+ debugUi: { showSessionId: false },
214
+ };
215
+ offlineNotice ??= unreachableReason(whoamiOutcome.reason);
216
+ }
217
+ else {
218
+ throw whoamiOutcome.reason;
220
219
  }
221
220
  if (offlineNotice) {
222
221
  console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
@@ -239,35 +238,44 @@ export async function main() {
239
238
  if (sourceSnapshot) {
240
239
  applySessionSnapshot(session, sourceSnapshot);
241
240
  }
242
- const projectIndex = createIndex({
243
- rootDir,
244
- onStatus: (message) => {
245
- if (message.trim()) {
246
- console.log(chalk.dim(` ${message}`));
247
- }
248
- },
249
- onContextLog: (message) => {
250
- if (message.trim()) {
251
- console.log(chalk.cyan(` ${message}`));
252
- }
253
- },
254
- });
241
+ startBrowserDiscoveryInBackground({ cwd: rootDir, env: session.env });
255
242
  const initialPrompt = prompt || undefined;
256
- await runClientInteractive({
257
- appendPromptHistory: (value) => appendPromptHistory(value, session.env),
258
- authConfig,
259
- debugUi: whoami.debugUi,
260
- projectIndex,
261
- serverModels,
262
- serverSessionClient,
263
- session,
264
- initialPrompt,
265
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
266
- });
267
- await saveSessionBoth({ session, serverSessionClient });
268
- printSessionExit(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;
260
+ }
261
+ await saveSessionBoth({ session, serverSessionClient });
262
+ printSessionExit(session);
263
+ }
264
+ finally {
265
+ await disposeSession(session);
266
+ }
269
267
  }
270
268
  main().catch((error) => {
271
- console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
269
+ if (isSignInCancelled(error)) {
270
+ console.error(chalk.dim('\nSign-in cancelled. Nothing was saved.\n'));
271
+ process.exit(130);
272
+ }
273
+ if (isAuthenticationError(error)) {
274
+ auth.clearCliAuthConfig();
275
+ console.error(chalk.red(`\n✖ Error: ${authenticationErrorMessage(error)}\n`));
276
+ }
277
+ else {
278
+ console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
279
+ }
272
280
  process.exit(1);
273
281
  });
@@ -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}`);