@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.
Files changed (55) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +134 -18
  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 +0 -16
  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,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
- const serverModels = await models.fetchServerModels({ config: authConfig });
319
- 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
+ }
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
- await mainInteractive({
381
- authConfig,
382
- projectIndex,
383
- serverModels,
384
- serverSessionClient,
385
- session,
386
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
387
- initialPrompt,
388
- });
389
- await saveSessionBoth({ session, serverSessionClient });
390
- 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
+ }
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>
@@ -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
  }
@@ -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.'));
@@ -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) {
@@ -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
- const trace = createTraceContext();
58
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
59
- headers: {
60
- authorization: `Bearer ${config.token}`,
61
- ...trace.headers,
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) {