@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.2
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 +138 -18
- 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 +0 -16
- package/dist/src/api/chat.js +59 -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 +97 -12
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scanner.js +50 -12
- package/dist/src/scratch-dir.js +75 -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 +194 -21
- package/dist/src/tools/delete-file.js +23 -5
- package/dist/src/tools/index.js +6 -0
- package/dist/src/tools/patch-file.js +33 -7
- 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 +17 -8
- package/dist/src/tools/replace-document-text.js +10 -12
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +109 -24
- package/dist/src/tools/run-node-script.js +27 -5
- 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 +33 -7
- 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 +26 -6
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +342 -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,6 +16,9 @@ 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';
|
|
21
|
+
import { setScratchSession } from '../src/scratch-dir.js';
|
|
18
22
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
19
23
|
const { auth, chat, models, sessions } = ServerApi;
|
|
20
24
|
function printUsage() {
|
|
@@ -36,9 +40,6 @@ function appendPromptHistory(prompt, env = process.env) {
|
|
|
36
40
|
}
|
|
37
41
|
async function runAuthCommand(command, args) {
|
|
38
42
|
if (command === 'login') {
|
|
39
|
-
// The public CLI always authenticates against the official TheGitAI host.
|
|
40
|
-
// There is intentionally no server/website override here — internal dev
|
|
41
|
-
// uses private tooling, not a customer-visible runtime override path.
|
|
42
43
|
const serverUrl = DEFAULT_SERVER_URL;
|
|
43
44
|
const noBrowser = args.includes('--no-browser');
|
|
44
45
|
console.log(chalk.dim(noBrowser
|
|
@@ -128,6 +129,15 @@ function modelLabel(serverModels, modelId) {
|
|
|
128
129
|
return (serverModels.models.find((model) => model.id === modelId)?.label ??
|
|
129
130
|
'Unknown model');
|
|
130
131
|
}
|
|
132
|
+
function formatJobElapsedMs(ms) {
|
|
133
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
134
|
+
if (totalSeconds < 60)
|
|
135
|
+
return `${totalSeconds}s`;
|
|
136
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
137
|
+
if (minutes < 60)
|
|
138
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
139
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
140
|
+
}
|
|
131
141
|
function makeConfirmCommand(session) {
|
|
132
142
|
return async (command) => {
|
|
133
143
|
console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
|
|
@@ -239,6 +249,68 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
239
249
|
console.log(chalk.dim('Conversation cleared.\n'));
|
|
240
250
|
continue;
|
|
241
251
|
}
|
|
252
|
+
if (trimmed === '/jobs' || trimmed.startsWith('/jobs ')) {
|
|
253
|
+
const jobsArgs = trimmed.slice('/jobs'.length).trim();
|
|
254
|
+
const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
|
|
255
|
+
if (killMatch) {
|
|
256
|
+
const jobId = killMatch[1];
|
|
257
|
+
const killed = await killBackgroundJob(jobId);
|
|
258
|
+
await collectBackgroundJobUiKillMutations({
|
|
259
|
+
session,
|
|
260
|
+
projectIndex,
|
|
261
|
+
jobId,
|
|
262
|
+
result: killed,
|
|
263
|
+
});
|
|
264
|
+
if (!killed.ok) {
|
|
265
|
+
console.log(chalk.red(killed.error ?? 'Background job kill failed.'));
|
|
266
|
+
}
|
|
267
|
+
else if (killed.alreadyFinished) {
|
|
268
|
+
console.log(chalk.dim(`${jobId} had already finished.`));
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
|
|
273
|
+
if (outputMatch) {
|
|
274
|
+
const jobId = outputMatch[1];
|
|
275
|
+
await collectBackgroundJobUiOutputMutations({
|
|
276
|
+
session,
|
|
277
|
+
projectIndex,
|
|
278
|
+
jobId,
|
|
279
|
+
});
|
|
280
|
+
const job = listBackgroundJobs().find((candidate) => candidate.id === jobId);
|
|
281
|
+
const buffered = getJobBufferedOutput(jobId);
|
|
282
|
+
if (!job || !buffered) {
|
|
283
|
+
console.log(chalk.red(`Unknown background job id: ${jobId}`));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (buffered.droppedChars > 0) {
|
|
287
|
+
console.log(chalk.dim(`... (${buffered.droppedChars} chars of older output dropped) ...`));
|
|
288
|
+
}
|
|
289
|
+
console.log(buffered.output || chalk.dim('(no output captured)'));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (jobsArgs) {
|
|
293
|
+
console.log(chalk.dim('Usage: /jobs — list · /jobs output <id> — full output · /jobs kill <id> — kill'));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const jobsList = listBackgroundJobs();
|
|
297
|
+
if (!jobsList.length) {
|
|
298
|
+
console.log(chalk.dim('No background jobs in this session.'));
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
for (const job of jobsList) {
|
|
302
|
+
const elapsed = formatJobElapsedMs((job.endedAt ?? Date.now()) - job.startedAt);
|
|
303
|
+
const stateText = job.status === 'running'
|
|
304
|
+
? `running · ${elapsed}`
|
|
305
|
+
: job.status === 'killed'
|
|
306
|
+
? `killed · ran ${elapsed}`
|
|
307
|
+
: job.status === 'error'
|
|
308
|
+
? 'failed to start'
|
|
309
|
+
: `exited (code ${job.exitCode ?? 1}) · ran ${elapsed}`;
|
|
310
|
+
console.log(`${job.id} · ${stateText}\n $ ${job.command}`);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
242
314
|
if (trimmed === '/resume') {
|
|
243
315
|
const snapshot = await promptForResumeSession(session.rootDir, serverModels);
|
|
244
316
|
if (!snapshot) {
|
|
@@ -246,6 +318,8 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
246
318
|
continue;
|
|
247
319
|
}
|
|
248
320
|
applySessionSnapshot(session, snapshot);
|
|
321
|
+
setBackgroundJobSession(session.sessionId);
|
|
322
|
+
setScratchSession(session.sessionId);
|
|
249
323
|
await saveSessionBoth({ session, serverSessionClient });
|
|
250
324
|
console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
|
|
251
325
|
continue;
|
|
@@ -314,9 +388,46 @@ export async function main() {
|
|
|
314
388
|
const rootDir = process.cwd();
|
|
315
389
|
const authConfig = requireCliAuthConfig();
|
|
316
390
|
const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
|
|
317
|
-
const cachedModels = models.readCachedServerModels();
|
|
318
|
-
|
|
319
|
-
|
|
391
|
+
const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
|
|
392
|
+
let offlineNotice = null;
|
|
393
|
+
let serverModels;
|
|
394
|
+
try {
|
|
395
|
+
serverModels = await models.fetchServerModels({ config: authConfig });
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
if (isTransientNetworkError(error) && cachedModels?.models.length) {
|
|
399
|
+
serverModels = { models: cachedModels.models };
|
|
400
|
+
offlineNotice = error?.message ? String(error.message) : 'network error';
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
let whoami;
|
|
407
|
+
try {
|
|
408
|
+
whoami = await auth.fetchWhoamiResponse({ config: authConfig });
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (isTransientNetworkError(error)) {
|
|
412
|
+
whoami = {
|
|
413
|
+
customer: {
|
|
414
|
+
id: '',
|
|
415
|
+
uuid: '',
|
|
416
|
+
email: authConfig.email,
|
|
417
|
+
customer_type: authConfig.customerType ?? 'USER',
|
|
418
|
+
scopes: [],
|
|
419
|
+
},
|
|
420
|
+
debugUi: { showSessionId: false },
|
|
421
|
+
};
|
|
422
|
+
offlineNotice ??= error?.message ? String(error.message) : 'network error';
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (offlineNotice) {
|
|
429
|
+
console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
|
|
430
|
+
}
|
|
320
431
|
if (listSessions) {
|
|
321
432
|
printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
|
|
322
433
|
return;
|
|
@@ -345,6 +456,7 @@ export async function main() {
|
|
|
345
456
|
applySessionSnapshot(session, sourceSnapshot);
|
|
346
457
|
await saveSessionBoth({ session, serverSessionClient });
|
|
347
458
|
}
|
|
459
|
+
setScratchSession(session.sessionId);
|
|
348
460
|
const projectIndex = createIndex({
|
|
349
461
|
rootDir,
|
|
350
462
|
onStatus: (message) => {
|
|
@@ -377,17 +489,25 @@ export async function main() {
|
|
|
377
489
|
}
|
|
378
490
|
printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
|
|
379
491
|
printSessionStartup(session);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
492
|
+
setBackgroundJobSession(session.sessionId);
|
|
493
|
+
try {
|
|
494
|
+
await mainInteractive({
|
|
495
|
+
authConfig,
|
|
496
|
+
projectIndex,
|
|
497
|
+
serverModels,
|
|
498
|
+
serverSessionClient,
|
|
499
|
+
session,
|
|
500
|
+
usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
|
|
501
|
+
initialPrompt,
|
|
502
|
+
});
|
|
503
|
+
await saveSessionBoth({ session, serverSessionClient });
|
|
504
|
+
printSessionExit(session);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
|
|
508
|
+
setBackgroundJobSession(null);
|
|
509
|
+
setScratchSession(null);
|
|
510
|
+
}
|
|
391
511
|
}
|
|
392
512
|
main().catch((error) => {
|
|
393
513
|
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
|
}
|
|
@@ -7,15 +7,10 @@ const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
|
|
|
7
7
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
9
|
function shutDownServer(server) {
|
|
10
|
-
// Drop any lingering (keep-alive) connections so the event loop empties and
|
|
11
|
-
// the CLI exits instead of hanging after a successful login.
|
|
12
10
|
server.closeAllConnections?.();
|
|
13
11
|
server.close();
|
|
14
12
|
}
|
|
15
13
|
export function resolveWebsiteUrl() {
|
|
16
|
-
// The public CLI always signs in through the official TheGitAI website. There
|
|
17
|
-
// is no override path here so the published package cannot be pointed at a
|
|
18
|
-
// clone host.
|
|
19
14
|
return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
|
|
20
15
|
}
|
|
21
16
|
function defaultDeviceName() {
|
|
@@ -26,7 +21,6 @@ function defaultDeviceName() {
|
|
|
26
21
|
return os.hostname();
|
|
27
22
|
}
|
|
28
23
|
}
|
|
29
|
-
/** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
|
|
30
24
|
export function generatePkce() {
|
|
31
25
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
32
26
|
const challenge = crypto
|
|
@@ -77,12 +71,6 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
|
|
|
77
71
|
customer,
|
|
78
72
|
};
|
|
79
73
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Browser-based login. Starts a loopback server so the website can redirect the
|
|
82
|
-
* one-time code back automatically; the code is then exchanged for a token
|
|
83
|
-
* using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
|
|
84
|
-
* The CLI never sees the user's credentials.
|
|
85
|
-
*/
|
|
86
74
|
export async function loginViaBrowser(options) {
|
|
87
75
|
const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
|
|
88
76
|
const websiteUrl = resolveWebsiteUrl();
|
|
@@ -97,8 +85,6 @@ export async function loginViaBrowser(options) {
|
|
|
97
85
|
deviceName,
|
|
98
86
|
paste: true,
|
|
99
87
|
});
|
|
100
|
-
// Headless mode: only print the URL for the user to open on another device.
|
|
101
|
-
// Never launch a browser here — that is the whole point of --no-browser.
|
|
102
88
|
onUrl(authUrl);
|
|
103
89
|
if (!options.promptCode) {
|
|
104
90
|
throw new Error('No way to read the authorization code in this context.');
|
|
@@ -125,8 +111,6 @@ export async function loginViaBrowser(options) {
|
|
|
125
111
|
}
|
|
126
112
|
const code = requestUrl.searchParams.get('code') ?? '';
|
|
127
113
|
const returnedState = requestUrl.searchParams.get('state') ?? '';
|
|
128
|
-
// `Connection: close` plus closeAllConnections() ensures the browser's
|
|
129
|
-
// keep-alive socket is torn down so the process can exit after login.
|
|
130
114
|
if (!code || returnedState !== state) {
|
|
131
115
|
res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
|
|
132
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,11 @@
|
|
|
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 { collectProjectOrientation } from '../project-orientation.js';
|
|
8
|
+
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
6
9
|
export class TurnCancelledError extends Error {
|
|
7
10
|
name = 'TurnCancelledError';
|
|
8
11
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -63,6 +66,11 @@ function snapshotForServer(session) {
|
|
|
63
66
|
snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
|
|
64
67
|
return snapshot;
|
|
65
68
|
}
|
|
69
|
+
function imageAttachmentsForServer(attachments) {
|
|
70
|
+
return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
|
|
71
|
+
? { ...attachment, filePath }
|
|
72
|
+
: attachment);
|
|
73
|
+
}
|
|
66
74
|
function userHistoryText(entry) {
|
|
67
75
|
return (entry.parts ?? [])
|
|
68
76
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
@@ -142,6 +150,35 @@ function publicStatusMessage(data) {
|
|
|
142
150
|
return `Running ${toolName} locally...`;
|
|
143
151
|
return null;
|
|
144
152
|
}
|
|
153
|
+
function normalizeShellJobToolCall(call) {
|
|
154
|
+
if (call.name !== 'shell_job_output' && call.name !== 'shell_job_kill') {
|
|
155
|
+
return call;
|
|
156
|
+
}
|
|
157
|
+
const args = call.args && typeof call.args === 'object' && !Array.isArray(call.args)
|
|
158
|
+
? { ...call.args }
|
|
159
|
+
: {};
|
|
160
|
+
let changed = false;
|
|
161
|
+
if (args.job_id === undefined) {
|
|
162
|
+
const alias = args.jobId ?? args.id;
|
|
163
|
+
if (alias !== undefined) {
|
|
164
|
+
args.job_id = alias;
|
|
165
|
+
delete args.jobId;
|
|
166
|
+
delete args.id;
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (call.name === 'shell_job_output' && args.wait_ms === undefined) {
|
|
171
|
+
const alias = args.waitMs ?? args.wait ?? args.wait_millis;
|
|
172
|
+
if (alias !== undefined) {
|
|
173
|
+
args.wait_ms = alias;
|
|
174
|
+
delete args.waitMs;
|
|
175
|
+
delete args.wait;
|
|
176
|
+
delete args.wait_millis;
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return changed ? { ...call, args } : call;
|
|
181
|
+
}
|
|
145
182
|
async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
|
|
146
183
|
const payload = {
|
|
147
184
|
toolCallId: event.call.id,
|
|
@@ -182,8 +219,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
182
219
|
}
|
|
183
220
|
}
|
|
184
221
|
try {
|
|
185
|
-
const
|
|
186
|
-
|
|
222
|
+
const call = normalizeShellJobToolCall(event.call);
|
|
223
|
+
const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
|
|
224
|
+
preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
|
|
187
225
|
if (signal?.aborted) {
|
|
188
226
|
throw new TurnCancelledError();
|
|
189
227
|
}
|
|
@@ -285,19 +323,32 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
285
323
|
return finalResult.current;
|
|
286
324
|
}
|
|
287
325
|
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
326
|
+
const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
|
|
327
|
+
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
328
|
+
? [...imageAttachments, ...autoAttach.attachments]
|
|
329
|
+
: imageAttachments;
|
|
330
|
+
const requestInputBase = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
331
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
332
|
+
sessionId: session.sessionId,
|
|
333
|
+
});
|
|
334
|
+
for (const err of autoAttach.errors) {
|
|
335
|
+
session.onStatus(`Image: ${err}`);
|
|
336
|
+
}
|
|
288
337
|
const request = {
|
|
289
338
|
modelId: session.modelId,
|
|
290
339
|
session: snapshotForServer(session),
|
|
291
|
-
input,
|
|
340
|
+
input: requestInputBase,
|
|
341
|
+
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
292
342
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
293
|
-
|
|
343
|
+
projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
|
|
344
|
+
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
294
345
|
maxToolSteps: session.maxToolSteps,
|
|
295
346
|
autoYes: session.autoYes,
|
|
296
347
|
agentMode: session.agentMode,
|
|
297
348
|
};
|
|
298
349
|
const trace = createTraceContext();
|
|
299
350
|
const preTurnHistoryLength = session.history.length;
|
|
300
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
351
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
|
|
301
352
|
if (signal?.aborted) {
|
|
302
353
|
preserveOnAbort();
|
|
303
354
|
}
|
|
@@ -324,7 +375,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
324
375
|
config,
|
|
325
376
|
projectIndex,
|
|
326
377
|
session,
|
|
327
|
-
input,
|
|
378
|
+
input: requestInputBase,
|
|
328
379
|
fetchImpl,
|
|
329
380
|
signal,
|
|
330
381
|
traceId: trace.traceId,
|
|
@@ -339,16 +390,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
390
|
}
|
|
340
391
|
catch (error) {
|
|
341
392
|
if (isTurnCancelledError(error)) {
|
|
342
|
-
preserveCancelledTurnInput(session,
|
|
393
|
+
preserveCancelledTurnInput(session, requestInputBase);
|
|
343
394
|
throw error instanceof TurnCancelledError
|
|
344
395
|
? error
|
|
345
396
|
: new TurnCancelledError();
|
|
346
397
|
}
|
|
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
398
|
session.history.length = preTurnHistoryLength;
|
|
351
|
-
preserveFailedTurnInput(session,
|
|
399
|
+
preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
352
400
|
throw error;
|
|
353
401
|
}
|
|
354
402
|
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) {
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -45,6 +45,11 @@ export function readCachedServerModels(env = process.env) {
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
export function selectCacheForServer(cached, serverUrl) {
|
|
49
|
+
if (!cached)
|
|
50
|
+
return null;
|
|
51
|
+
return cached.serverUrl === normalizeServerUrl(serverUrl) ? cached : null;
|
|
52
|
+
}
|
|
48
53
|
export function writeCachedServerModels(cache, env = process.env) {
|
|
49
54
|
const filePath = getModelsCachePath(env);
|
|
50
55
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
@@ -54,26 +59,27 @@ export function writeCachedServerModels(cache, env = process.env) {
|
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
return retryTransient(async () => {
|
|
63
|
+
const trace = createTraceContext();
|
|
64
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
|
|
65
|
+
headers: {
|
|
66
|
+
authorization: `Bearer ${config.token}`,
|
|
67
|
+
...trace.headers,
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
70
|
+
});
|
|
71
|
+
const data = (await readJsonResponse(response));
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
74
|
+
}
|
|
75
|
+
const models = Array.isArray(data?.models)
|
|
76
|
+
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
77
|
+
: [];
|
|
78
|
+
if (models.length === 0) {
|
|
79
|
+
throw new Error('Server returned an invalid model list.');
|
|
80
|
+
}
|
|
81
|
+
return { models };
|
|
63
82
|
});
|
|
64
|
-
const data = (await readJsonResponse(response));
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
67
|
-
}
|
|
68
|
-
const models = Array.isArray(data?.models)
|
|
69
|
-
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
70
|
-
: [];
|
|
71
|
-
if (models.length === 0) {
|
|
72
|
-
throw new Error('Server returned an invalid model list.');
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
models,
|
|
76
|
-
};
|
|
77
83
|
}
|
|
78
84
|
export function selectServerModel({ requestedModelId, cached, serverModels, }) {
|
|
79
85
|
const supportedIds = new Set(serverModels.models.map((model) => model.id));
|