@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.
Files changed (55) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +135 -26
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +3 -3
  6. package/dist/src/api/browser-login.js +3 -38
  7. package/dist/src/api/chat.js +57 -11
  8. package/dist/src/api/http.js +49 -1
  9. package/dist/src/api/models.js +26 -20
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +11 -6
  19. package/dist/src/markdown-renderer.js +1 -1
  20. package/dist/src/patcher.js +1 -3
  21. package/dist/src/scanner.js +50 -12
  22. package/dist/src/scratch-dir.js +57 -0
  23. package/dist/src/secret-preview.js +0 -10
  24. package/dist/src/session-safety.js +0 -19
  25. package/dist/src/session-store.js +0 -1
  26. package/dist/src/todo-list.js +106 -0
  27. package/dist/src/tool-executor.js +159 -18
  28. package/dist/src/tools/delete-file.js +1 -1
  29. package/dist/src/tools/index.js +6 -0
  30. package/dist/src/tools/patch-file.js +3 -2
  31. package/dist/src/tools/path-suggest.js +81 -8
  32. package/dist/src/tools/read-document.js +2 -2
  33. package/dist/src/tools/read-file.js +14 -7
  34. package/dist/src/tools/replace-document-text.js +3 -11
  35. package/dist/src/tools/restore-checkpoint.js +1 -1
  36. package/dist/src/tools/run-command.js +83 -16
  37. package/dist/src/tools/run-node-script.js +3 -1
  38. package/dist/src/tools/shell-job-kill.js +48 -0
  39. package/dist/src/tools/shell-job-output.js +51 -0
  40. package/dist/src/tools/str-replace.js +3 -2
  41. package/dist/src/tools/undo-edit.js +1 -1
  42. package/dist/src/tools/update-todos.js +27 -0
  43. package/dist/src/tools/write-file.js +1 -1
  44. package/dist/src/tree-sitter-runtime.js +8 -1
  45. package/dist/src/ui/repl.js +313 -23
  46. package/dist/src/ui/tui/bridge.js +0 -4
  47. package/dist/src/ui/tui/build-frame.js +220 -24
  48. package/dist/src/ui/tui/shell-input.js +33 -4
  49. package/dist/src/ui/tui/terminal-title.js +81 -0
  50. package/dist/src/version.js +0 -6
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. 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
- reads code, edits files, and runs commands with your approval.
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 'chalk';
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 = commandFlagValue(args, '--server') ??
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
- const serverModels = await models.fetchServerModels({ config: authConfig });
326
- const whoami = await auth.fetchWhoamiResponse({ config: authConfig });
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
- await mainInteractive({
388
- authConfig,
389
- projectIndex,
390
- serverModels,
391
- serverSessionClient,
392
- session,
393
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
394
- initialPrompt,
395
- });
396
- await saveSessionBoth({ session, serverSessionClient });
397
- printSessionExit(session);
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>
@@ -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;
@@ -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 isLocalhostUrl(url) {
17
- if (!url)
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(options.websiteUrl, process.env, serverUrl);
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.'));
@@ -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 rawResult = await executeLocalToolCall({ projectIndex }, session, event.call);
186
- preserveCancelledTurnToolResult(session, input, event, rawResult);
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, input);
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, input);
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, input, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
397
+ preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
352
398
  throw error;
353
399
  }
354
400
  finally {
@@ -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) {