@thegitai/cli 1.0.0-beta.9 → 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 +134 -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 +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,6 +16,8 @@ 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() {
|
|
@@ -36,9 +39,6 @@ function appendPromptHistory(prompt, env = process.env) {
|
|
|
36
39
|
}
|
|
37
40
|
async function runAuthCommand(command, args) {
|
|
38
41
|
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
42
|
const serverUrl = DEFAULT_SERVER_URL;
|
|
43
43
|
const noBrowser = args.includes('--no-browser');
|
|
44
44
|
console.log(chalk.dim(noBrowser
|
|
@@ -128,6 +128,15 @@ function modelLabel(serverModels, modelId) {
|
|
|
128
128
|
return (serverModels.models.find((model) => model.id === modelId)?.label ??
|
|
129
129
|
'Unknown model');
|
|
130
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
|
+
}
|
|
131
140
|
function makeConfirmCommand(session) {
|
|
132
141
|
return async (command) => {
|
|
133
142
|
console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
|
|
@@ -239,6 +248,68 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
239
248
|
console.log(chalk.dim('Conversation cleared.\n'));
|
|
240
249
|
continue;
|
|
241
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
|
+
}
|
|
242
313
|
if (trimmed === '/resume') {
|
|
243
314
|
const snapshot = await promptForResumeSession(session.rootDir, serverModels);
|
|
244
315
|
if (!snapshot) {
|
|
@@ -246,6 +317,7 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
246
317
|
continue;
|
|
247
318
|
}
|
|
248
319
|
applySessionSnapshot(session, snapshot);
|
|
320
|
+
setBackgroundJobSession(session.sessionId);
|
|
249
321
|
await saveSessionBoth({ session, serverSessionClient });
|
|
250
322
|
console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
|
|
251
323
|
continue;
|
|
@@ -314,9 +386,46 @@ export async function main() {
|
|
|
314
386
|
const rootDir = process.cwd();
|
|
315
387
|
const authConfig = requireCliAuthConfig();
|
|
316
388
|
const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
|
|
317
|
-
const cachedModels = models.readCachedServerModels();
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
}
|
|
320
429
|
if (listSessions) {
|
|
321
430
|
printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
|
|
322
431
|
return;
|
|
@@ -377,17 +486,24 @@ export async function main() {
|
|
|
377
486
|
}
|
|
378
487
|
printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
|
|
379
488
|
printSessionStartup(session);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
+
}
|
|
391
507
|
}
|
|
392
508
|
main().catch((error) => {
|
|
393
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
|
}
|
|
@@ -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,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) {
|
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));
|
|
@@ -154,6 +154,8 @@ const SENSITIVE_BASENAME_PATTERNS = [
|
|
|
154
154
|
/^\.?pypirc$/i,
|
|
155
155
|
/^credentials(?:\..*)?$/i,
|
|
156
156
|
/^secrets?(?:\..*)?$/i,
|
|
157
|
+
/^service[-_]?account(?:\..*)?\.json$/i,
|
|
158
|
+
/^.*credentials.*\.json$/i,
|
|
157
159
|
];
|
|
158
160
|
const SENSITIVE_PATH_PATTERNS = [
|
|
159
161
|
/(^|[/\\])\.aws[/\\]credentials$/i,
|
|
@@ -161,6 +163,7 @@ const SENSITIVE_PATH_PATTERNS = [
|
|
|
161
163
|
/(^|[/\\])credentials?([._-]|$)/i,
|
|
162
164
|
/(^|[/\\])secrets?([._-]|$)/i,
|
|
163
165
|
/(^|[/\\])private[-_]?key([._-]|$)/i,
|
|
166
|
+
/(^|[/\\])service[-_]?account/i,
|
|
164
167
|
/\.(?:pem|key|p12|pfx)$/i,
|
|
165
168
|
];
|
|
166
169
|
export function normalizeArtifactPath(relPath) {
|