@thegitai/cli 1.0.0-beta.8 → 1.0.0-preview.1
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 +36 -2
- package/dist/bin/ai.js +135 -26
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +3 -3
- package/dist/src/api/browser-login.js +3 -38
- package/dist/src/api/chat.js +57 -11
- package/dist/src/api/http.js +49 -1
- package/dist/src/api/models.js +26 -20
- package/dist/src/artifact-policy.js +3 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +0 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +19 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +11 -6
- package/dist/src/markdown-renderer.js +1 -1
- package/dist/src/patcher.js +1 -3
- package/dist/src/scanner.js +50 -12
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +0 -19
- package/dist/src/session-store.js +0 -1
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +159 -18
- package/dist/src/tools/delete-file.js +1 -1
- package/dist/src/tools/index.js +6 -0
- package/dist/src/tools/patch-file.js +3 -2
- package/dist/src/tools/path-suggest.js +81 -8
- package/dist/src/tools/read-document.js +2 -2
- package/dist/src/tools/read-file.js +14 -7
- package/dist/src/tools/replace-document-text.js +3 -11
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +83 -16
- package/dist/src/tools/run-node-script.js +3 -1
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/tools/str-replace.js +3 -2
- package/dist/src/tools/undo-edit.js +1 -1
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +1 -1
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +313 -23
- package/dist/src/ui/tui/bridge.js +0 -4
- package/dist/src/ui/tui/build-frame.js +220 -24
- package/dist/src/ui/tui/shell-input.js +33 -4
- package/dist/src/ui/tui/terminal-title.js +81 -0
- package/dist/src/version.js +0 -6
- package/dist/vendor/web-tree-sitter/LICENSE +21 -0
- package/dist/vendor/web-tree-sitter/NOTICE +13 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +14 -15
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# TheGitAI
|
|
2
2
|
|
|
3
|
-
Interactive terminal coding agent. Talk to your repo in plain English — it
|
|
4
|
-
|
|
3
|
+
Interactive terminal coding agent. Talk to your repo in plain English — it reads
|
|
4
|
+
your code, makes edits, runs the commands a task needs, and keeps long-running
|
|
5
|
+
processes going while you work, all with your approval.
|
|
5
6
|
|
|
6
7
|
## Install
|
|
7
8
|
|
|
@@ -25,6 +26,39 @@ ai --version print the version and exit
|
|
|
25
26
|
|
|
26
27
|
Run `ai --help` for sessions, modes, keys, and chat commands.
|
|
27
28
|
|
|
29
|
+
## Visible to-do list
|
|
30
|
+
|
|
31
|
+
For larger multi-step tasks, the agent keeps a compact to-do list on screen so
|
|
32
|
+
you can see what it plans to do, what it is working on right now, and what is
|
|
33
|
+
already done.
|
|
34
|
+
|
|
35
|
+
- While the agent works, the list sits at the bottom of the live **Working**
|
|
36
|
+
area, right above where you type, and updates as steps start and finish —
|
|
37
|
+
one step in progress at a time — so it stays visible even when tool output
|
|
38
|
+
above it runs long.
|
|
39
|
+
- When the turn ends, a final snapshot of the list stays readable in the
|
|
40
|
+
transcript, and the footer shows a small progress chip (e.g. `◐ 4/6 to-dos`)
|
|
41
|
+
while steps remain open.
|
|
42
|
+
- The agent's current reasoning stays visible too, right below the list, in a
|
|
43
|
+
compact one-line form so it doesn't compete with the list for space.
|
|
44
|
+
- The list is managed entirely by the agent; simple one-step requests skip it.
|
|
45
|
+
|
|
46
|
+
## Background jobs
|
|
47
|
+
|
|
48
|
+
Some commands are meant to keep running — a dev server, a file watcher, a local
|
|
49
|
+
API. TheGitAI runs these as **background jobs**, so the agent can start a server,
|
|
50
|
+
work against it live, and stop it when the task is done, all in one session.
|
|
51
|
+
|
|
52
|
+
- Each job's block in the transcript updates on its own with a live tail of its
|
|
53
|
+
latest output, and a compact indicator keeps you aware of what's still running.
|
|
54
|
+
- Type `/jobs` to open a picker: **↑ / ↓** to move, **Enter** to expand a job and
|
|
55
|
+
read its output inline, **k** to stop it, **Esc** to close.
|
|
56
|
+
- `/jobs output <id>` prints a job's full output, and `/jobs kill <id>` stops one
|
|
57
|
+
by id.
|
|
58
|
+
- When you end the session, TheGitAI stops its background jobs for you.
|
|
59
|
+
|
|
60
|
+
Full documentation: <https://thegit.ai/docs>
|
|
61
|
+
|
|
28
62
|
## License
|
|
29
63
|
|
|
30
64
|
Proprietary — see the LICENSE file included in this package. Source is
|
package/dist/bin/ai.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import chalk from '
|
|
2
|
+
import chalk from '../src/colors.js';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
5
|
import { ServerApi } from '../src/api/index.js';
|
|
6
6
|
import { loginViaBrowser } from '../src/api/browser-login.js';
|
|
7
|
+
import { isTransientNetworkError } from '../src/api/http.js';
|
|
7
8
|
import { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
|
|
8
9
|
import { renderMarkdownForTerminal } from '../src/markdown-renderer.js';
|
|
9
10
|
import { createIndex } from '../src/project-index.js';
|
|
@@ -15,17 +16,13 @@ import { formatSessionExitNotice } from '../src/session-exit.js';
|
|
|
15
16
|
import { formatUsageText } from '../src/usage.js';
|
|
16
17
|
import { formatVersionLine } from '../src/version.js';
|
|
17
18
|
import { parseArgs } from '../src/cli-args.js';
|
|
19
|
+
import { getJobBufferedOutput, killBackgroundJob, killAllBackgroundJobs, listBackgroundJobs, setBackgroundJobSession, } from '../src/background-jobs.js';
|
|
20
|
+
import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../src/tool-executor.js';
|
|
18
21
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
19
22
|
const { auth, chat, models, sessions } = ServerApi;
|
|
20
23
|
function printUsage() {
|
|
21
24
|
console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
|
|
22
25
|
}
|
|
23
|
-
function commandFlagValue(args, name) {
|
|
24
|
-
const index = args.indexOf(name);
|
|
25
|
-
if (index === -1)
|
|
26
|
-
return null;
|
|
27
|
-
return args[index + 1] ?? null;
|
|
28
|
-
}
|
|
29
26
|
async function promptText(question, fallback = null) {
|
|
30
27
|
const rl = readline.createInterface({ input, output });
|
|
31
28
|
try {
|
|
@@ -42,17 +39,13 @@ function appendPromptHistory(prompt, env = process.env) {
|
|
|
42
39
|
}
|
|
43
40
|
async function runAuthCommand(command, args) {
|
|
44
41
|
if (command === 'login') {
|
|
45
|
-
const serverUrl =
|
|
46
|
-
process.env.THEGITAI_SERVER_URL ??
|
|
47
|
-
DEFAULT_SERVER_URL;
|
|
48
|
-
const websiteUrl = commandFlagValue(args, '--website') ?? undefined;
|
|
42
|
+
const serverUrl = DEFAULT_SERVER_URL;
|
|
49
43
|
const noBrowser = args.includes('--no-browser');
|
|
50
44
|
console.log(chalk.dim(noBrowser
|
|
51
45
|
? 'Sign in on the website, then paste the authorization code here.'
|
|
52
46
|
: 'Opening your browser to sign in…'));
|
|
53
47
|
const result = await loginViaBrowser({
|
|
54
48
|
serverUrl,
|
|
55
|
-
websiteUrl,
|
|
56
49
|
noBrowser,
|
|
57
50
|
onUrl: (url) => {
|
|
58
51
|
console.log(chalk.dim(noBrowser ? 'Open this URL to sign in:' : 'If your browser did not open, visit:'));
|
|
@@ -135,6 +128,15 @@ function modelLabel(serverModels, modelId) {
|
|
|
135
128
|
return (serverModels.models.find((model) => model.id === modelId)?.label ??
|
|
136
129
|
'Unknown model');
|
|
137
130
|
}
|
|
131
|
+
function formatJobElapsedMs(ms) {
|
|
132
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
133
|
+
if (totalSeconds < 60)
|
|
134
|
+
return `${totalSeconds}s`;
|
|
135
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
136
|
+
if (minutes < 60)
|
|
137
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
138
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
139
|
+
}
|
|
138
140
|
function makeConfirmCommand(session) {
|
|
139
141
|
return async (command) => {
|
|
140
142
|
console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
|
|
@@ -246,6 +248,68 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
246
248
|
console.log(chalk.dim('Conversation cleared.\n'));
|
|
247
249
|
continue;
|
|
248
250
|
}
|
|
251
|
+
if (trimmed === '/jobs' || trimmed.startsWith('/jobs ')) {
|
|
252
|
+
const jobsArgs = trimmed.slice('/jobs'.length).trim();
|
|
253
|
+
const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
|
|
254
|
+
if (killMatch) {
|
|
255
|
+
const jobId = killMatch[1];
|
|
256
|
+
const killed = await killBackgroundJob(jobId);
|
|
257
|
+
await collectBackgroundJobUiKillMutations({
|
|
258
|
+
session,
|
|
259
|
+
projectIndex,
|
|
260
|
+
jobId,
|
|
261
|
+
result: killed,
|
|
262
|
+
});
|
|
263
|
+
if (!killed.ok) {
|
|
264
|
+
console.log(chalk.red(killed.error ?? 'Background job kill failed.'));
|
|
265
|
+
}
|
|
266
|
+
else if (killed.alreadyFinished) {
|
|
267
|
+
console.log(chalk.dim(`${jobId} had already finished.`));
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
|
|
272
|
+
if (outputMatch) {
|
|
273
|
+
const jobId = outputMatch[1];
|
|
274
|
+
await collectBackgroundJobUiOutputMutations({
|
|
275
|
+
session,
|
|
276
|
+
projectIndex,
|
|
277
|
+
jobId,
|
|
278
|
+
});
|
|
279
|
+
const job = listBackgroundJobs().find((candidate) => candidate.id === jobId);
|
|
280
|
+
const buffered = getJobBufferedOutput(jobId);
|
|
281
|
+
if (!job || !buffered) {
|
|
282
|
+
console.log(chalk.red(`Unknown background job id: ${jobId}`));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (buffered.droppedChars > 0) {
|
|
286
|
+
console.log(chalk.dim(`... (${buffered.droppedChars} chars of older output dropped) ...`));
|
|
287
|
+
}
|
|
288
|
+
console.log(buffered.output || chalk.dim('(no output captured)'));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (jobsArgs) {
|
|
292
|
+
console.log(chalk.dim('Usage: /jobs — list · /jobs output <id> — full output · /jobs kill <id> — kill'));
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const jobsList = listBackgroundJobs();
|
|
296
|
+
if (!jobsList.length) {
|
|
297
|
+
console.log(chalk.dim('No background jobs in this session.'));
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const job of jobsList) {
|
|
301
|
+
const elapsed = formatJobElapsedMs((job.endedAt ?? Date.now()) - job.startedAt);
|
|
302
|
+
const stateText = job.status === 'running'
|
|
303
|
+
? `running · ${elapsed}`
|
|
304
|
+
: job.status === 'killed'
|
|
305
|
+
? `killed · ran ${elapsed}`
|
|
306
|
+
: job.status === 'error'
|
|
307
|
+
? 'failed to start'
|
|
308
|
+
: `exited (code ${job.exitCode ?? 1}) · ran ${elapsed}`;
|
|
309
|
+
console.log(`${job.id} · ${stateText}\n $ ${job.command}`);
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
249
313
|
if (trimmed === '/resume') {
|
|
250
314
|
const snapshot = await promptForResumeSession(session.rootDir, serverModels);
|
|
251
315
|
if (!snapshot) {
|
|
@@ -253,6 +317,7 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
253
317
|
continue;
|
|
254
318
|
}
|
|
255
319
|
applySessionSnapshot(session, snapshot);
|
|
320
|
+
setBackgroundJobSession(session.sessionId);
|
|
256
321
|
await saveSessionBoth({ session, serverSessionClient });
|
|
257
322
|
console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
|
|
258
323
|
continue;
|
|
@@ -321,9 +386,46 @@ export async function main() {
|
|
|
321
386
|
const rootDir = process.cwd();
|
|
322
387
|
const authConfig = requireCliAuthConfig();
|
|
323
388
|
const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
|
|
324
|
-
const cachedModels = models.readCachedServerModels();
|
|
325
|
-
|
|
326
|
-
|
|
389
|
+
const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
|
|
390
|
+
let offlineNotice = null;
|
|
391
|
+
let serverModels;
|
|
392
|
+
try {
|
|
393
|
+
serverModels = await models.fetchServerModels({ config: authConfig });
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (isTransientNetworkError(error) && cachedModels?.models.length) {
|
|
397
|
+
serverModels = { models: cachedModels.models };
|
|
398
|
+
offlineNotice = error?.message ? String(error.message) : 'network error';
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
let whoami;
|
|
405
|
+
try {
|
|
406
|
+
whoami = await auth.fetchWhoamiResponse({ config: authConfig });
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (isTransientNetworkError(error)) {
|
|
410
|
+
whoami = {
|
|
411
|
+
customer: {
|
|
412
|
+
id: '',
|
|
413
|
+
uuid: '',
|
|
414
|
+
email: authConfig.email,
|
|
415
|
+
customer_type: authConfig.customerType ?? 'USER',
|
|
416
|
+
scopes: [],
|
|
417
|
+
},
|
|
418
|
+
debugUi: { showSessionId: false },
|
|
419
|
+
};
|
|
420
|
+
offlineNotice ??= error?.message ? String(error.message) : 'network error';
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (offlineNotice) {
|
|
427
|
+
console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
|
|
428
|
+
}
|
|
327
429
|
if (listSessions) {
|
|
328
430
|
printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
|
|
329
431
|
return;
|
|
@@ -384,17 +486,24 @@ export async function main() {
|
|
|
384
486
|
}
|
|
385
487
|
printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
|
|
386
488
|
printSessionStartup(session);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
489
|
+
setBackgroundJobSession(session.sessionId);
|
|
490
|
+
try {
|
|
491
|
+
await mainInteractive({
|
|
492
|
+
authConfig,
|
|
493
|
+
projectIndex,
|
|
494
|
+
serverModels,
|
|
495
|
+
serverSessionClient,
|
|
496
|
+
session,
|
|
497
|
+
usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
|
|
498
|
+
initialPrompt,
|
|
499
|
+
});
|
|
500
|
+
await saveSessionBoth({ session, serverSessionClient });
|
|
501
|
+
printSessionExit(session);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
|
|
505
|
+
setBackgroundJobSession(null);
|
|
506
|
+
}
|
|
398
507
|
}
|
|
399
508
|
main().catch((error) => {
|
|
400
509
|
console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Tree-sitter grammar parsers
|
|
2
|
+
===========================
|
|
3
|
+
|
|
4
|
+
The `tree-sitter-*.wasm` files in this directory are precompiled tree-sitter
|
|
5
|
+
grammar parsers, vendored so the published `@thegitai/cli` package ships local
|
|
6
|
+
code intelligence without a runtime dependency. They are used unmodified, only
|
|
7
|
+
as parsing inputs to the vendored web-tree-sitter runtime
|
|
8
|
+
(see ../vendor/web-tree-sitter/NOTICE).
|
|
9
|
+
|
|
10
|
+
Each grammar is the work of its respective tree-sitter grammar project and is
|
|
11
|
+
distributed under that project's license (the tree-sitter grammars are
|
|
12
|
+
MIT-licensed). Grammars included:
|
|
13
|
+
|
|
14
|
+
c, c-sharp, cpp, css, go, html, java, javascript, objc, php, python, ruby,
|
|
15
|
+
rust, tsx, typescript
|
|
16
|
+
|
|
17
|
+
Upstream organization: https://github.com/tree-sitter
|
|
18
|
+
Individual grammars: https://github.com/tree-sitter/tree-sitter-<language>
|
package/dist/src/agent-mode.js
CHANGED
|
@@ -12,6 +12,8 @@ const PLAN_MODE_TOOL_NAMES = new Set([
|
|
|
12
12
|
'read_document',
|
|
13
13
|
'analyze_image',
|
|
14
14
|
'run_command',
|
|
15
|
+
'shell_job_output',
|
|
16
|
+
'update_todos',
|
|
15
17
|
]);
|
|
16
18
|
const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
|
|
17
19
|
'pwd',
|
|
@@ -117,6 +119,9 @@ export function buildAgentModeToolBlockedResult(mode, call) {
|
|
|
117
119
|
}
|
|
118
120
|
if (call.name !== 'run_command')
|
|
119
121
|
return null;
|
|
122
|
+
if (call.args?.background === true) {
|
|
123
|
+
return buildPlanModeToolBlockedResult(call.name, PLAN_MODE_RUN_COMMAND_ACTION);
|
|
124
|
+
}
|
|
120
125
|
const command = String(call.args?.command ?? call.args?.cmd ?? '');
|
|
121
126
|
const reason = planModeRunCommandBlockReason(command);
|
|
122
127
|
return reason ? buildPlanModeToolBlockedResult(call.name, reason) : null;
|
package/dist/src/api/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
4
|
+
import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
export function getAuthConfigPath(env = process.env) {
|
|
6
6
|
const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
|
|
7
7
|
if (configured) {
|
|
@@ -51,11 +51,11 @@ export async function fetchWhoami({ config, fetchImpl = globalThis.fetch, }) {
|
|
|
51
51
|
return data.customer;
|
|
52
52
|
}
|
|
53
53
|
export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, }) {
|
|
54
|
-
const data = (await authorizedJson({
|
|
54
|
+
const data = (await retryTransient(() => authorizedJson({
|
|
55
55
|
config,
|
|
56
56
|
path: '/v1/auth/whoami',
|
|
57
57
|
fetchImpl,
|
|
58
|
-
}));
|
|
58
|
+
})));
|
|
59
59
|
if (!data?.customer?.email) {
|
|
60
60
|
throw new Error('Server returned an invalid whoami response.');
|
|
61
61
|
}
|
|
@@ -4,38 +4,14 @@ import os from 'node:os';
|
|
|
4
4
|
import { openUrl } from '../core/open-url.js';
|
|
5
5
|
import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
6
6
|
const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
|
|
7
|
-
const DEFAULT_DEV_WEBSITE_URL = 'http://localhost:3002';
|
|
8
7
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
9
8
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
9
|
function shutDownServer(server) {
|
|
11
|
-
// Drop any lingering (keep-alive) connections so the event loop empties and
|
|
12
|
-
// the CLI exits instead of hanging after a successful login.
|
|
13
10
|
server.closeAllConnections?.();
|
|
14
11
|
server.close();
|
|
15
12
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
return false;
|
|
19
|
-
try {
|
|
20
|
-
const host = new URL(url).hostname;
|
|
21
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
export function resolveWebsiteUrl(websiteUrl, env = process.env, serverUrl) {
|
|
28
|
-
const explicit = String(websiteUrl ?? '').trim() ||
|
|
29
|
-
String(env.THEGITAI_WEBSITE_URL ?? '').trim();
|
|
30
|
-
// With no explicit override, point at production — unless we're clearly in
|
|
31
|
-
// local dev (talking to a localhost server), in which case default to the
|
|
32
|
-
// local website so `ai login` works without any flags or env vars.
|
|
33
|
-
const value = explicit || (isLocalhostUrl(serverUrl) ? DEFAULT_DEV_WEBSITE_URL : DEFAULT_WEBSITE_URL);
|
|
34
|
-
const normalized = value.replace(/\/+$/, '');
|
|
35
|
-
if (!/^https?:\/\//i.test(normalized)) {
|
|
36
|
-
throw new Error('Website URL must start with http:// or https://.');
|
|
37
|
-
}
|
|
38
|
-
return normalized;
|
|
13
|
+
export function resolveWebsiteUrl() {
|
|
14
|
+
return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
|
|
39
15
|
}
|
|
40
16
|
function defaultDeviceName() {
|
|
41
17
|
try {
|
|
@@ -45,7 +21,6 @@ function defaultDeviceName() {
|
|
|
45
21
|
return os.hostname();
|
|
46
22
|
}
|
|
47
23
|
}
|
|
48
|
-
/** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
|
|
49
24
|
export function generatePkce() {
|
|
50
25
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
51
26
|
const challenge = crypto
|
|
@@ -96,15 +71,9 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
|
|
|
96
71
|
customer,
|
|
97
72
|
};
|
|
98
73
|
}
|
|
99
|
-
/**
|
|
100
|
-
* Browser-based login. Starts a loopback server so the website can redirect the
|
|
101
|
-
* one-time code back automatically; the code is then exchanged for a token
|
|
102
|
-
* using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
|
|
103
|
-
* The CLI never sees the user's credentials.
|
|
104
|
-
*/
|
|
105
74
|
export async function loginViaBrowser(options) {
|
|
106
75
|
const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
|
|
107
|
-
const websiteUrl = resolveWebsiteUrl(
|
|
76
|
+
const websiteUrl = resolveWebsiteUrl();
|
|
108
77
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
109
78
|
const openBrowser = options.openBrowser ?? openUrl;
|
|
110
79
|
const onUrl = options.onUrl ?? (() => { });
|
|
@@ -116,8 +85,6 @@ export async function loginViaBrowser(options) {
|
|
|
116
85
|
deviceName,
|
|
117
86
|
paste: true,
|
|
118
87
|
});
|
|
119
|
-
// Headless mode: only print the URL for the user to open on another device.
|
|
120
|
-
// Never launch a browser here — that is the whole point of --no-browser.
|
|
121
88
|
onUrl(authUrl);
|
|
122
89
|
if (!options.promptCode) {
|
|
123
90
|
throw new Error('No way to read the authorization code in this context.');
|
|
@@ -144,8 +111,6 @@ export async function loginViaBrowser(options) {
|
|
|
144
111
|
}
|
|
145
112
|
const code = requestUrl.searchParams.get('code') ?? '';
|
|
146
113
|
const returnedState = requestUrl.searchParams.get('state') ?? '';
|
|
147
|
-
// `Connection: close` plus closeAllConnections() ensures the browser's
|
|
148
|
-
// keep-alive socket is torn down so the process can exit after login.
|
|
149
114
|
if (!code || returnedState !== state) {
|
|
150
115
|
res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
|
|
151
116
|
res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
|
package/dist/src/api/chat.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { drainBackgroundJobNotifications } from '../background-jobs.js';
|
|
1
2
|
import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
|
|
2
3
|
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
5
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
6
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
7
|
+
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
6
8
|
export class TurnCancelledError extends Error {
|
|
7
9
|
name = 'TurnCancelledError';
|
|
8
10
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -63,6 +65,11 @@ function snapshotForServer(session) {
|
|
|
63
65
|
snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
|
|
64
66
|
return snapshot;
|
|
65
67
|
}
|
|
68
|
+
function imageAttachmentsForServer(attachments) {
|
|
69
|
+
return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
|
|
70
|
+
? { ...attachment, filePath }
|
|
71
|
+
: attachment);
|
|
72
|
+
}
|
|
66
73
|
function userHistoryText(entry) {
|
|
67
74
|
return (entry.parts ?? [])
|
|
68
75
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
@@ -142,6 +149,35 @@ function publicStatusMessage(data) {
|
|
|
142
149
|
return `Running ${toolName} locally...`;
|
|
143
150
|
return null;
|
|
144
151
|
}
|
|
152
|
+
function normalizeShellJobToolCall(call) {
|
|
153
|
+
if (call.name !== 'shell_job_output' && call.name !== 'shell_job_kill') {
|
|
154
|
+
return call;
|
|
155
|
+
}
|
|
156
|
+
const args = call.args && typeof call.args === 'object' && !Array.isArray(call.args)
|
|
157
|
+
? { ...call.args }
|
|
158
|
+
: {};
|
|
159
|
+
let changed = false;
|
|
160
|
+
if (args.job_id === undefined) {
|
|
161
|
+
const alias = args.jobId ?? args.id;
|
|
162
|
+
if (alias !== undefined) {
|
|
163
|
+
args.job_id = alias;
|
|
164
|
+
delete args.jobId;
|
|
165
|
+
delete args.id;
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (call.name === 'shell_job_output' && args.wait_ms === undefined) {
|
|
170
|
+
const alias = args.waitMs ?? args.wait ?? args.wait_millis;
|
|
171
|
+
if (alias !== undefined) {
|
|
172
|
+
args.wait_ms = alias;
|
|
173
|
+
delete args.waitMs;
|
|
174
|
+
delete args.wait;
|
|
175
|
+
delete args.wait_millis;
|
|
176
|
+
changed = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return changed ? { ...call, args } : call;
|
|
180
|
+
}
|
|
145
181
|
async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
|
|
146
182
|
const payload = {
|
|
147
183
|
toolCallId: event.call.id,
|
|
@@ -182,8 +218,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
182
218
|
}
|
|
183
219
|
}
|
|
184
220
|
try {
|
|
185
|
-
const
|
|
186
|
-
|
|
221
|
+
const call = normalizeShellJobToolCall(event.call);
|
|
222
|
+
const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
|
|
223
|
+
preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
|
|
187
224
|
if (signal?.aborted) {
|
|
188
225
|
throw new TurnCancelledError();
|
|
189
226
|
}
|
|
@@ -285,19 +322,31 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
285
322
|
return finalResult.current;
|
|
286
323
|
}
|
|
287
324
|
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
325
|
+
const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
|
|
326
|
+
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
327
|
+
? [...imageAttachments, ...autoAttach.attachments]
|
|
328
|
+
: imageAttachments;
|
|
329
|
+
const requestInputBase = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
330
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
331
|
+
sessionId: session.sessionId,
|
|
332
|
+
});
|
|
333
|
+
for (const err of autoAttach.errors) {
|
|
334
|
+
session.onStatus(`Image: ${err}`);
|
|
335
|
+
}
|
|
288
336
|
const request = {
|
|
289
337
|
modelId: session.modelId,
|
|
290
338
|
session: snapshotForServer(session),
|
|
291
|
-
input,
|
|
339
|
+
input: requestInputBase,
|
|
340
|
+
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
292
341
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
293
|
-
imageAttachments,
|
|
342
|
+
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
294
343
|
maxToolSteps: session.maxToolSteps,
|
|
295
344
|
autoYes: session.autoYes,
|
|
296
345
|
agentMode: session.agentMode,
|
|
297
346
|
};
|
|
298
347
|
const trace = createTraceContext();
|
|
299
348
|
const preTurnHistoryLength = session.history.length;
|
|
300
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
349
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
|
|
301
350
|
if (signal?.aborted) {
|
|
302
351
|
preserveOnAbort();
|
|
303
352
|
}
|
|
@@ -324,7 +373,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
324
373
|
config,
|
|
325
374
|
projectIndex,
|
|
326
375
|
session,
|
|
327
|
-
input,
|
|
376
|
+
input: requestInputBase,
|
|
328
377
|
fetchImpl,
|
|
329
378
|
signal,
|
|
330
379
|
traceId: trace.traceId,
|
|
@@ -339,16 +388,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
388
|
}
|
|
340
389
|
catch (error) {
|
|
341
390
|
if (isTurnCancelledError(error)) {
|
|
342
|
-
preserveCancelledTurnInput(session,
|
|
391
|
+
preserveCancelledTurnInput(session, requestInputBase);
|
|
343
392
|
throw error instanceof TurnCancelledError
|
|
344
393
|
? error
|
|
345
394
|
: new TurnCancelledError();
|
|
346
395
|
}
|
|
347
|
-
// Non-cancel failures (e.g. upstream connection errors) must not leave
|
|
348
|
-
// speculative cancelled-turn entries in history — otherwise the next
|
|
349
|
-
// request replays a malformed transcript to the server.
|
|
350
396
|
session.history.length = preTurnHistoryLength;
|
|
351
|
-
preserveFailedTurnInput(session,
|
|
397
|
+
preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
352
398
|
throw error;
|
|
353
399
|
}
|
|
354
400
|
finally {
|
package/dist/src/api/http.js
CHANGED
|
@@ -26,6 +26,53 @@ export function createTraceContext(traceId = createTraceId()) {
|
|
|
26
26
|
},
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
export const REQUEST_TIMEOUT_MS = 8000;
|
|
30
|
+
const TRANSIENT_NETWORK_CODES = new Set([
|
|
31
|
+
'ECONNRESET',
|
|
32
|
+
'ECONNREFUSED',
|
|
33
|
+
'ETIMEDOUT',
|
|
34
|
+
'EAI_AGAIN',
|
|
35
|
+
'ENOTFOUND',
|
|
36
|
+
'ENETUNREACH',
|
|
37
|
+
'EHOSTUNREACH',
|
|
38
|
+
'EPIPE',
|
|
39
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
40
|
+
'UND_ERR_SOCKET',
|
|
41
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
42
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
43
|
+
]);
|
|
44
|
+
export function isTransientNetworkError(error) {
|
|
45
|
+
if (error instanceof ServerApiError)
|
|
46
|
+
return false;
|
|
47
|
+
if (!error || typeof error !== 'object')
|
|
48
|
+
return false;
|
|
49
|
+
const err = error;
|
|
50
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError')
|
|
51
|
+
return true;
|
|
52
|
+
const code = err.code ?? err.cause?.code;
|
|
53
|
+
if (code) {
|
|
54
|
+
return TRANSIENT_NETWORK_CODES.has(code);
|
|
55
|
+
}
|
|
56
|
+
if (error instanceof TypeError && /fetch failed/i.test(err.message ?? '')) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
return await run();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (attempt >= retries || !isTransientNetworkError(error))
|
|
69
|
+
throw error;
|
|
70
|
+
const delayMs = baseDelayMs * 2 ** attempt;
|
|
71
|
+
attempt += 1;
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
29
76
|
export function normalizeServerUrl(serverUrl) {
|
|
30
77
|
const normalized = String(serverUrl || DEFAULT_SERVER_URL)
|
|
31
78
|
.trim()
|
|
@@ -53,7 +100,7 @@ export async function readErrorResponse(response, traceId = response.headers.get
|
|
|
53
100
|
const data = await readJsonResponse(response);
|
|
54
101
|
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
55
102
|
}
|
|
56
|
-
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
|
|
103
|
+
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
57
104
|
const trace = createTraceContext();
|
|
58
105
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
|
|
59
106
|
method,
|
|
@@ -64,6 +111,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
64
111
|
...(body === null ? {} : { 'content-type': 'application/json' }),
|
|
65
112
|
},
|
|
66
113
|
body: body === null ? undefined : JSON.stringify(body),
|
|
114
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
67
115
|
});
|
|
68
116
|
const data = await readJsonResponse(response);
|
|
69
117
|
if (!response.ok) {
|