@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.40
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 +49 -4
- package/dist/bin/ai.js +119 -111
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +11 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +286 -44
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +127 -7
- package/dist/src/api/models.js +29 -10
- package/dist/src/background-jobs.js +4 -2
- package/dist/src/browser/bridge.js +232 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +209 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +104 -0
- package/dist/src/cli-args.js +19 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +246 -0
- package/dist/src/edit-journal.js +33 -2
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +58 -17
- package/dist/src/patcher.js +43 -4
- package/dist/src/permissions.js +288 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +19 -4
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +12 -46
- package/dist/src/tools/browser.js +628 -0
- package/dist/src/tools/delete-file.js +20 -4
- package/dist/src/tools/index.js +32 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +802 -195
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +517 -165
- package/dist/src/ui/tui/composer-layout.js +76 -0
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +227 -67
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +20 -8
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
7
|
-
import {
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
+
async function ensureCliAuthConfig() {
|
|
81
66
|
const config = auth.readCliAuthConfig();
|
|
82
|
-
if (
|
|
83
|
-
|
|
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
|
|
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,
|
|
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
|
|
145
|
+
if (command && command !== 'login') {
|
|
146
|
+
await runAuthCommand(command);
|
|
158
147
|
return;
|
|
159
148
|
}
|
|
160
149
|
if (usage) {
|
|
161
|
-
const authConfig =
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
188
|
-
serverModels =
|
|
186
|
+
if (modelsOutcome.status === 'fulfilled') {
|
|
187
|
+
serverModels = modelsOutcome.value;
|
|
189
188
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
whoami =
|
|
201
|
+
if (whoamiOutcome.status === 'fulfilled') {
|
|
202
|
+
whoami = whoamiOutcome.value;
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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}`);
|