@thegitai/cli 1.0.0-beta.14 → 1.0.0-beta.16

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/dist/bin/ai.js CHANGED
@@ -4,6 +4,7 @@ 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';
@@ -36,9 +37,6 @@ function appendPromptHistory(prompt, env = process.env) {
36
37
  }
37
38
  async function runAuthCommand(command, args) {
38
39
  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
40
  const serverUrl = DEFAULT_SERVER_URL;
43
41
  const noBrowser = args.includes('--no-browser');
44
42
  console.log(chalk.dim(noBrowser
@@ -314,9 +312,46 @@ export async function main() {
314
312
  const rootDir = process.cwd();
315
313
  const authConfig = requireCliAuthConfig();
316
314
  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 });
315
+ const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
316
+ let offlineNotice = null;
317
+ let serverModels;
318
+ try {
319
+ serverModels = await models.fetchServerModels({ config: authConfig });
320
+ }
321
+ catch (error) {
322
+ if (isTransientNetworkError(error) && cachedModels?.models.length) {
323
+ serverModels = { models: cachedModels.models };
324
+ offlineNotice = error?.message ? String(error.message) : 'network error';
325
+ }
326
+ else {
327
+ throw error;
328
+ }
329
+ }
330
+ let whoami;
331
+ try {
332
+ whoami = await auth.fetchWhoamiResponse({ config: authConfig });
333
+ }
334
+ catch (error) {
335
+ if (isTransientNetworkError(error)) {
336
+ whoami = {
337
+ customer: {
338
+ id: '',
339
+ uuid: '',
340
+ email: authConfig.email,
341
+ customer_type: authConfig.customerType ?? 'USER',
342
+ scopes: [],
343
+ },
344
+ debugUi: { showSessionId: false },
345
+ };
346
+ offlineNotice ??= error?.message ? String(error.message) : 'network error';
347
+ }
348
+ else {
349
+ throw error;
350
+ }
351
+ }
352
+ if (offlineNotice) {
353
+ console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
354
+ }
320
355
  if (listSessions) {
321
356
  printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
322
357
  return;
@@ -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.'));
@@ -65,7 +65,9 @@ function snapshotForServer(session) {
65
65
  return snapshot;
66
66
  }
67
67
  function imageAttachmentsForServer(attachments) {
68
- return (attachments ?? []).map(({ filePath: _filePath, ...attachment }) => attachment);
68
+ return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
69
+ ? { ...attachment, filePath }
70
+ : attachment);
69
71
  }
70
72
  function userHistoryText(entry) {
71
73
  return (entry.parts ?? [])
@@ -293,12 +295,6 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
293
295
  const requestImageAttachments = autoAttach.attachments.length > 0
294
296
  ? [...imageAttachments, ...autoAttach.attachments]
295
297
  : imageAttachments;
296
- // requestInput is the server-bound, path-sanitized text ([Image #N] markers in
297
- // place of local image paths). Every history-preservation path below (abort,
298
- // cancel, failure, mid-stream tool cancel) must persist THIS, never the raw
299
- // input — otherwise a cancelled or failed image turn leaks the local
300
- // filesystem path into session history and the next snapshot ships it to the
301
- // server, defeating the point of sanitizing before the request crosses over.
302
298
  const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
303
299
  for (const err of autoAttach.errors) {
304
300
  session.onStatus(`Image: ${err}`);
@@ -362,9 +358,6 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
362
358
  ? error
363
359
  : new TurnCancelledError();
364
360
  }
365
- // Non-cancel failures (e.g. upstream connection errors) must not leave
366
- // speculative cancelled-turn entries in history — otherwise the next
367
- // request replays a malformed transcript to the server.
368
361
  session.history.length = preTurnHistoryLength;
369
362
  preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
370
363
  throw error;
@@ -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));
@@ -148,10 +148,6 @@ export const ARTIFACT_FALLBACK_IGNORE_GLOBS = [
148
148
  '**/pnpm-lock.yaml',
149
149
  ...ARTIFACT_IGNORE_PATH_PREFIXES.map((prefix) => `${prefix}/**`),
150
150
  ];
151
- // Kept in lockstep with the server's secret-path check: the client repair guard
152
- // and the server-side secret check must agree on what counts as a secret, or a
153
- // quoted/curly secret path the client repairs (e.g. service-account.json) slips
154
- // past the server check, which keys off the original tool-call args.
155
151
  const SENSITIVE_BASENAME_PATTERNS = [
156
152
  /^\.env(?:\..+)?$/i,
157
153
  /^\.?npmrc$/i,
@@ -39,11 +39,6 @@ export function parseArgs(argv) {
39
39
  usage = true;
40
40
  continue;
41
41
  }
42
- // An unrecognized dashed token is a mistyped flag, not prompt text. Without
43
- // an auth subcommand (whose flags are parsed separately) it would otherwise
44
- // be swept into the prompt and silently start a billable session. Flag the
45
- // first one so the caller can fail fast instead. Quoted prompts are a single
46
- // argv entry with spaces, so they never look like a bare option here.
47
42
  if (command === null && unknownOption === null && /^-/.test(arg)) {
48
43
  unknownOption = arg;
49
44
  continue;
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, readFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { ensureSessionScratchDir } from './scratch-dir.js';
4
5
  const PACKAGE_MANAGER_CANDIDATES = [
5
6
  'apt',
6
7
  'apt-get',
@@ -123,5 +124,6 @@ export function collectClientEnvironment(options = {}) {
123
124
  shell: detectShell(platform, env),
124
125
  ...linuxDistro,
125
126
  packageManagers: detectPackageManagers(env, platform, executableExists),
127
+ scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
126
128
  };
127
129
  }
@@ -1,10 +1,4 @@
1
- // Dependency-free ANSI styler with a chalk-compatible surface, imported as
2
- // `chalk` at call sites. Color gating (NO_COLOR / FORCE_COLOR / non-TTY) lives
3
- // in colorEnabled() below.
4
1
  const STYLE_NAMES = ['bold', 'dim', 'red', 'green', 'yellow', 'cyan'];
5
- // SGR open/close codes. Bold and dim share the 22 reset; colors share 39, so a
6
- // nested inner style restores exactly its own attribute without clearing the
7
- // outer one.
8
2
  const OPEN = {
9
3
  bold: '\x1b[1m',
10
4
  dim: '\x1b[2m',
@@ -33,8 +27,6 @@ function colorEnabled() {
33
27
  function applyStyle(name, text) {
34
28
  const open = OPEN[name];
35
29
  const close = CLOSE[name];
36
- // Re-open this style after any inner close of the same code, so a nested
37
- // style (e.g. chalk.red(`a ${chalk.bold('b')} c`)) doesn't terminate it early.
38
30
  const body = text.includes(close) ? text.split(close).join(close + open) : text;
39
31
  return open + body + close;
40
32
  }
@@ -43,7 +35,6 @@ function createStyler(styles) {
43
35
  const value = String(text);
44
36
  if (!colorEnabled() || styles.length === 0)
45
37
  return value;
46
- // Apply right-to-left so the first style in the chain is outermost.
47
38
  return styles.reduceRight((acc, name) => applyStyle(name, acc), value);
48
39
  });
49
40
  for (const name of STYLE_NAMES) {
@@ -1,36 +1,64 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadImageFromFile } from './clipboard.js';
5
5
  const EXT = '(?:png|jpe?g|gif|webp)';
6
- // Bare-path characters: exclude whitespace, quotes, prose terminators, ':' (so we
7
- // don't swallow URL schemes or trailing "see foo.png:" prose) and bracket/brace/paren
8
- // delimiters on both sides (so markdown "![alt](x.png)" / "(x.png)" don't leak in).
9
6
  const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
10
- // A bare token may carry backslash-escaped spaces (terminal drag-and-drop) and an
11
- // optional Windows drive prefix ("C:\\" or "C:/").
12
7
  const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
13
8
  const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
14
9
  `|'([^']*\\.${EXT})'` +
15
10
  `|file://(\\S*\\.${EXT})` +
16
11
  `|(${BARE_PATH})`, 'gi');
12
+ const EXTENSION_TOKEN = new RegExp(`^(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+$`);
13
+ const MAX_EXTENSION_TOKENS = 8;
14
+ function isFile(p) {
15
+ try {
16
+ return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function extendBareMatchAcrossSpaces(input, matchStart, raw, cwd) {
23
+ const baseInner = raw.replace(/\\ /g, ' ');
24
+ const baseExists = isFile(path.isAbsolute(baseInner) ? baseInner : path.resolve(cwd, baseInner));
25
+ let best = null;
26
+ let candidate = raw;
27
+ let start = matchStart;
28
+ let addedSeparator = false;
29
+ for (let hops = 0; hops < MAX_EXTENSION_TOKENS; hops++) {
30
+ if (start < 2 || input[start - 1] !== ' ' || /\s/.test(input[start - 2]))
31
+ break;
32
+ let i = start - 2;
33
+ while (i >= 0 && !/\s/.test(input[i]))
34
+ i--;
35
+ const tokenStart = i + 1;
36
+ const token = input.slice(tokenStart, start - 1);
37
+ if (!EXTENSION_TOKEN.test(token))
38
+ break;
39
+ candidate = `${token} ${candidate}`;
40
+ start = tokenStart;
41
+ addedSeparator = addedSeparator || /[\\/]/.test(token);
42
+ if (baseExists && !addedSeparator)
43
+ continue;
44
+ const inner = candidate.replace(/\\ /g, ' ');
45
+ const resolved = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
46
+ if (isFile(resolved))
47
+ best = candidate;
48
+ }
49
+ return best ?? raw;
50
+ }
17
51
  function detectImagePaths(input, cwd) {
18
52
  const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
19
- const rawsByPath = new Map();
53
+ const detected = [];
20
54
  let match;
21
55
  while ((match = regex.exec(input)) !== null) {
22
- const raw = match[0];
23
- // A bare match containing "://" is a URL scheme remnant (e.g. the "p://" tail
24
- // of "http://…" caught by the drive-letter prefix), never a local file path.
25
- // Scope this to the bare branch only — a quoted value may legitimately be a
26
- // file:// URL, which the quoted branch below decodes.
56
+ let raw = match[0];
27
57
  if (match[4] != null && raw.includes('://'))
28
58
  continue;
29
59
  let inner;
30
60
  if (match[1] != null || match[2] != null) {
31
61
  const quoted = (match[1] ?? match[2]);
32
- // A quoted value can itself be a file:// URL ("file:///a/b%20c.png"); it
33
- // still needs percent-decoding, so route it through the same decoder.
34
62
  if (/^file:\/\//i.test(quoted)) {
35
63
  try {
36
64
  inner = fileURLToPath(quoted);
@@ -44,9 +72,6 @@ function detectImagePaths(input, cwd) {
44
72
  }
45
73
  }
46
74
  else if (match[3] != null) {
47
- // file:// URL: decode percent-escapes (e.g. "%20") to the real filesystem
48
- // path. A malformed URL (bad %-escape) must be skipped, never thrown:
49
- // ordinary prose containing a broken file:// URL should not abort the turn.
50
75
  try {
51
76
  inner = fileURLToPath(raw);
52
77
  }
@@ -60,10 +85,21 @@ function detectImagePaths(input, cwd) {
60
85
  }
61
86
  }
62
87
  else {
63
- // Bare path: unescape "\\ " sequences produced by terminal drag-and-drop.
64
- inner = match[4].replace(/\\ /g, ' ');
88
+ raw = extendBareMatchAcrossSpaces(input, match.index, raw, cwd);
89
+ inner = raw.replace(/\\ /g, ' ');
65
90
  }
66
91
  const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
92
+ const start = match.index - (raw.length - match[0].length);
93
+ while (detected.length > 0) {
94
+ const prev = detected[detected.length - 1];
95
+ if (prev.start + prev.raw.length <= start)
96
+ break;
97
+ detected.pop();
98
+ }
99
+ detected.push({ resolvedPath, raw, start });
100
+ }
101
+ const rawsByPath = new Map();
102
+ for (const { resolvedPath, raw } of detected) {
67
103
  const existing = rawsByPath.get(resolvedPath);
68
104
  if (existing) {
69
105
  existing.push(raw);
@@ -80,10 +116,6 @@ export function autoAttachImages(input, cwd, existing = []) {
80
116
  let sanitizedInput = input;
81
117
  const attachments = [];
82
118
  const errors = [];
83
- // Seed new markers after the highest existing index, not the existing count.
84
- // The TUI can submit a sparse attachment set (e.g. [Image #2] kept after
85
- // [Image #1] was deleted from the prompt), so counting would reuse an index
86
- // still present in the prompt and make the model inspect the wrong image.
87
119
  const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
88
120
  for (const [resolvedPath, rawForms] of rawsByPath) {
89
121
  if (existing.length + attachments.length >= max)
@@ -6,12 +6,9 @@ import os from 'os';
6
6
  import path from 'path';
7
7
  import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
8
8
  import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
9
+ import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
9
10
  const requireFromHere = createRequire(import.meta.url);
10
11
  let nodePtyCache;
11
- // @lydell/node-pty ships its native binding as platform-specific optional
12
- // packages. If none is installed for this OS/arch the require throws; we cache
13
- // the failure and fall back to the non-interactive child_process path. A pty is
14
- // only needed to answer interactive sudo prompts.
15
12
  function loadNodePty() {
16
13
  if (nodePtyCache !== undefined)
17
14
  return nodePtyCache;
@@ -221,6 +218,7 @@ function isExistingDirectory(absPath) {
221
218
  return false;
222
219
  }
223
220
  }
221
+ const OS_TEMP_BLOCK_MARKER = 'os-temp:';
224
222
  function getBlockedOsTempInspection(rawToken, rootDir) {
225
223
  const token = normalizeToken(rawToken);
226
224
  if (!token || !path.isAbsolute(token))
@@ -228,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
228
226
  const resolved = path.resolve(token);
229
227
  if (!isInsideOsTemp(resolved))
230
228
  return null;
229
+ if (isInsideTheGitAiScratch(resolved))
230
+ return null;
231
231
  if (rootDir) {
232
232
  const resolvedRoot = path.resolve(rootDir);
233
233
  if (resolved === resolvedRoot ||
@@ -238,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
238
238
  if (resolved === path.resolve(os.tmpdir()) ||
239
239
  hasPathGlob(token) ||
240
240
  isExistingDirectory(resolved)) {
241
- return path.basename(os.tmpdir()) || os.tmpdir();
241
+ return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
242
242
  }
243
243
  return null;
244
244
  }
@@ -254,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
254
254
  const resolved = path.isAbsolute(token)
255
255
  ? path.resolve(token)
256
256
  : path.resolve(baseDir ?? rootDir, token);
257
+ const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
258
+ if (osTempResolved)
259
+ return osTempResolved;
257
260
  return getBlockedProjectPathDir(resolved, rootDir);
258
261
  }
259
262
  if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
@@ -390,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
390
393
  }
391
394
  return null;
392
395
  }
396
+ function expandTempEnvRefs(command) {
397
+ return command
398
+ .replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
399
+ .replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
400
+ }
393
401
  function findIgnoredPathInspection(command, rootDir) {
394
402
  if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
395
403
  return null;
396
404
  }
397
- const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
405
+ const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
398
406
  const baseDir = getCommandBaseDir(haystack, rootDir);
399
407
  const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
400
408
  if (blockedDir) {
@@ -554,10 +562,17 @@ function terminateChild(child, signal) {
554
562
  }
555
563
  }
556
564
  export function getBlockedPathInspectDir(command, rootDir) {
557
- return findIgnoredPathInspection(command, rootDir);
565
+ const blocked = findIgnoredPathInspection(command, rootDir);
566
+ return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
567
+ ? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
568
+ : blocked;
558
569
  }
559
570
  export function getBlockedCommandReason(command, hasTimeout, rootDir) {
560
571
  const ignoredDir = findIgnoredPathInspection(command, rootDir);
572
+ if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
573
+ const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
574
+ return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
575
+ }
561
576
  if (ignoredDir) {
562
577
  return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
563
578
  }
@@ -619,6 +634,7 @@ function buildCommandEnv(cwd) {
619
634
  npm_config_fund: 'false',
620
635
  npm_config_audit: 'false',
621
636
  NUXI_INIT_SKIP_PROMPT: 'true',
637
+ THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
622
638
  };
623
639
  }
624
640
  function sanitizePtyOutput(command, output, cwd, secrets) {
@@ -797,9 +813,6 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
797
813
  if (nodePty) {
798
814
  return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
799
815
  }
800
- // No pty binding installed for this platform — fall through to the
801
- // non-interactive spawn path. The command still runs; an interactive sudo
802
- // prompt simply can't be answered here.
803
816
  }
804
817
  return new Promise((resolve) => {
805
818
  let stdout = '';
@@ -1,9 +1,5 @@
1
1
  import chalk from './colors.js';
2
2
  import { getCliVersion, getPlatformTag } from './version.js';
3
- // The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
4
- // a terminal. The one thing that genuinely differs is the terminal's paste
5
- // shortcut, so surface the one for the host OS (right-click paste works
6
- // everywhere regardless).
7
3
  function pasteShortcutForPlatform() {
8
4
  switch (process.platform) {
9
5
  case 'darwin':
@@ -104,7 +100,6 @@ const HELP_MARKDOWN = [
104
100
  ' message — there is no client-side debug mode by design.',
105
101
  ].join('\n');
106
102
  export function formatAboutCard() {
107
- // Fenced so the column alignment survives terminal markdown rendering.
108
103
  return [
109
104
  '```',
110
105
  'TheGitAI',
@@ -162,7 +162,6 @@ export function writeProjectFile(rootDir, filePath, content) {
162
162
  }
163
163
  }
164
164
  catch {
165
- // If we can't read it for some reason, proceed with write
166
165
  }
167
166
  }
168
167
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -179,7 +178,6 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
179
178
  }
180
179
  }
181
180
  catch {
182
- // If we can't read it for some reason, proceed with write
183
181
  }
184
182
  }
185
183
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -42,12 +42,6 @@ function isFallbackIgnoredFile(relPath, fileName) {
42
42
  return true;
43
43
  return FALLBACK_LOCKFILES.has(fileName);
44
44
  }
45
- // Local stand-in for the previous glob('**/*', { nodir: true, dot: false,
46
- // ignore: FALLBACK_IGNORE }) call, used only when `git ls-files` fails (e.g. a
47
- // non-git directory). Walks the tree depth-first, skips dotfiles and
48
- // dot-directories (glob's dot: false), prunes ignored artifact dirs, and drops
49
- // lockfiles plus sensitive/ignored paths — reproducing the old fallback without
50
- // the glob dependency.
51
45
  function walkProjectFilesFallback(rootDir, limit) {
52
46
  const results = [];
53
47
  const visit = (relDir) => {
@@ -70,9 +64,6 @@ function walkProjectFilesFallback(rootDir, limit) {
70
64
  continue;
71
65
  const relPath = relDir ? `${relDir}/${name}` : name;
72
66
  if (entry.isDirectory()) {
73
- // Only the prefix-based artifact rule is safe to prune a directory by;
74
- // the sensitive-basename check must stay at the file level so a dir
75
- // merely named e.g. `secret` doesn't hide non-sensitive files under it.
76
67
  if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
77
68
  continue;
78
69
  }
@@ -0,0 +1,57 @@
1
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ let cachedScratchDir = null;
5
+ export function sessionScratchDir() {
6
+ if (!cachedScratchDir) {
7
+ cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
+ }
9
+ return cachedScratchDir;
10
+ }
11
+ function isSquattedScratchRoot(dir) {
12
+ try {
13
+ const st = lstatSync(dir);
14
+ if (st.isSymbolicLink() || !st.isDirectory())
15
+ return true;
16
+ if (process.platform !== 'win32' &&
17
+ typeof process.getuid === 'function' &&
18
+ st.uid !== process.getuid()) {
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function ensureSessionScratchDir() {
28
+ const dir = sessionScratchDir();
29
+ try {
30
+ if (isSquattedScratchRoot(dir)) {
31
+ cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
+ return cachedScratchDir;
33
+ }
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ if (process.platform !== 'win32') {
36
+ chmodSync(dir, 0o700);
37
+ }
38
+ }
39
+ catch {
40
+ }
41
+ return sessionScratchDir();
42
+ }
43
+ export function isInsideTheGitAiScratch(absPath) {
44
+ const tempRoot = path.resolve(os.tmpdir());
45
+ const relPath = path.relative(tempRoot, path.resolve(absPath));
46
+ if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
47
+ return false;
48
+ }
49
+ const first = relPath.split(/[\\/]/, 1)[0] ?? '';
50
+ if (!/^thegitai(?:$|[-.])/i.test(first)) {
51
+ return false;
52
+ }
53
+ if (/[*?[\]{}]/.test(first)) {
54
+ return false;
55
+ }
56
+ return !isSquattedScratchRoot(path.join(tempRoot, first));
57
+ }
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
6
6
  const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
7
7
  const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
8
8
  const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
9
- // Password embedded in a connection-string URL, e.g.
10
- // `postgresql://user:PASS@host`. Redacted from shell output so secrets in
11
- // commands like `cat .env` do not leak into history or telemetry.
12
9
  const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
13
- /** Redact only userinfo passwords in connection-string URLs (zero false positives). */
14
10
  export function redactConnectionStringCredentials(text) {
15
11
  return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
16
12
  }
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
72
68
  const base = path.posix.basename(text.replace(/\\/g, '/'));
73
69
  return DOTENV_BASENAME_PATTERN.test(base);
74
70
  }
75
- /**
76
- * True only for a clean dotenv file we can safely show with keys visible and
77
- * values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
78
- * is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
79
- * raw secret) returns false so the caller keeps the opaque blackout instead.
80
- */
81
71
  export function looksLikeEditableDotenv(content) {
82
72
  PEM_BLOCK_PATTERN.lastIndex = 0;
83
73
  if (PEM_BLOCK_PATTERN.test(content))
@@ -454,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
454
454
  }
455
455
  const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
456
456
  const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
457
- /**
458
- * Redact a dotenv file's values while leaving keys visible. Every assignment's
459
- * value is replaced with a stable, reversible token so the agent can see the
460
- * file's structure and edit it (remove or replace lines) without ever seeing a
461
- * secret value; `resolveRedactionTokens` swaps the real values back on write.
462
- * Comment bodies are tokenized too, because developers routinely leave
463
- * commented-out credentials in dotenv files and those must not leak where the
464
- * opaque preview would have hidden them. Callers must confirm the content is
465
- * clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
466
- * lines reaching here are blanks and comments.
467
- */
468
457
  export function redactDotenvWithStableTokens(state, content, filePath, hash) {
469
458
  const tokens = [];
470
459
  const redactedLines = content.split('\n').map((line) => {
@@ -490,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
490
479
  });
491
480
  return { content: redactedLines.join('\n'), tokens };
492
481
  }
493
- /**
494
- * The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
495
- * emits more tokens than that would evict its own oldest tokens, leaving
496
- * `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
497
- * longer resolve (silently writing the literal token back). So a dotenv file
498
- * with more tokenizable lines than the budget must not use the editable preview
499
- * — the caller falls back to the opaque blackout instead.
500
- */
501
482
  export function dotenvFitsRedactionBudget(content) {
502
483
  let count = 0;
503
484
  for (const line of content.split('\n')) {
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
122
122
  snapshots.push(loadSnapshotFile(filePath, rootDir));
123
123
  }
124
124
  catch {
125
- // Skip corrupted snapshots silently — customers have no actionable debug path here.
126
125
  }
127
126
  }
128
127
  return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -46,10 +46,6 @@ function getEditToolFilePath(call) {
46
46
  }
47
47
  return '';
48
48
  }
49
- // replace_document_text repairs its source filePath but writes a separate
50
- // outputPath verbatim (a write target must not be fold-matched onto a different
51
- // existing file). When an outputPath is given, the snapshot path it returns is
52
- // that raw output, so it must not be repaired.
53
49
  function editToolWritesSeparateOutput(call) {
54
50
  if (call.name !== 'replace_document_text')
55
51
  return false;
@@ -131,13 +127,6 @@ export async function executeLocalToolCall(toolContext, session, call) {
131
127
  session.onToolEvent?.({ call, result });
132
128
  return result;
133
129
  }
134
- // Snapshot the real file the edit tool will touch. Tools that repair their
135
- // path internally (str_replace/patch_file/replace_document_text) must be
136
- // snapshotted against the repaired path, or the pre-edit snapshot targets
137
- // the unrepaired path and the edit is journaled as a `create` and undone by
138
- // deleting the user's file. write_file/delete_file consume the raw path, so
139
- // repairing their snapshot would instead journal a phantom edit of a
140
- // different file — keep them on the raw path.
141
130
  const rawEditFilePath = isEditToolName(call.name)
142
131
  ? getEditToolFilePath(call)
143
132
  : '';
@@ -1,9 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isSensitiveProjectPath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
4
- // "File not found" recovery hint: when a model mistypes a filename (most
5
- // often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
6
- // suggest the closest real file from the same directory.
7
4
  function foldName(name) {
8
5
  return name
9
6
  .normalize('NFC')
@@ -46,9 +43,6 @@ export function suggestClosestPath(rootDir, missingPath) {
46
43
  let best = null;
47
44
  let bestDistance = Number.POSITIVE_INFINITY;
48
45
  for (const candidate of candidates) {
49
- // Never suggest a file the caller would refuse to read/write directly:
50
- // probing a near-miss like `.enx` or `credential.docx` must not leak the
51
- // existence of `.env`/credentials through the recovery hint.
52
46
  const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
53
47
  if (isSensitiveProjectPath(candidateRelative))
54
48
  continue;
@@ -64,10 +58,6 @@ export function suggestClosestPath(rootDir, missingPath) {
64
58
  const relative = path.relative(rootDir, suggested);
65
59
  return relative && !relative.startsWith('..') ? relative : suggested;
66
60
  }
67
- // Fold only the punctuation/whitespace a model routinely alters when it echoes
68
- // a filename — a curly apostrophe ’ flattened to a straight ', smart double
69
- // quotes, and a non-breaking space — WITHOUT touching case, so a path is only
70
- // auto-corrected when nothing but this punctuation differs from a real file.
71
61
  function foldPunctuation(name) {
72
62
  return name
73
63
  .normalize('NFC')
@@ -75,9 +65,6 @@ function foldPunctuation(name) {
75
65
  .replace(/[“”]/g, '"')
76
66
  .replace(/ /g, ' ');
77
67
  }
78
- // Strip ONE matched pair of surrounding quotes. A path pasted from a file
79
- // manager's "Copy as path" or dragged into a terminal arrives wrapped in
80
- // '…' / "…", and that wrapping is captured verbatim as part of the filename.
81
68
  function stripSurroundingQuotes(p) {
82
69
  if (p.length >= 2) {
83
70
  const first = p[0];
@@ -88,9 +75,6 @@ function stripSurroundingQuotes(p) {
88
75
  }
89
76
  return p;
90
77
  }
91
- // A single backslash is a legal filename byte on POSIX, but models routinely
92
- // double it (it is JSON's escape character) when echoing a name, turning
93
- // `back\slash.js` into `back\\slash.js`. Collapse doubled backslashes to one.
94
78
  function collapseDoubledBackslashes(p) {
95
79
  return p.replace(/\\\\/g, '\\');
96
80
  }
@@ -105,36 +89,17 @@ function existsAgainst(rootDir, p) {
105
89
  return false;
106
90
  }
107
91
  }
108
- // Repair must never resolve a protected file (a secret like `.env`/credentials).
109
- // Repair only runs when the literal path is missing, so without this a quoted or
110
- // curly-flattened secret path — which used to fail as not-found — would be
111
- // silently resolved to the real secret, bypassing the redaction that keys off
112
- // the original tool-call args (e.g. read_document, str_replace, patch_file).
113
92
  function isProtectedRepairTarget(rootDir, candidate) {
114
93
  const rel = path.relative(rootDir, resolveAgainst(rootDir, candidate));
115
94
  const projectPath = rel && !rel.startsWith('..') ? rel : candidate;
116
95
  return (isSensitiveProjectPath(projectPath) ||
117
96
  (rel !== '' && !rel.startsWith('..') && shouldIgnoreArtifactPath(rel)));
118
97
  }
119
- // Edit tools that call repairFilePath on their path argument internally. The
120
- // executor repairs the pre-edit snapshot path only for these, so its snapshot
121
- // targets the same file the tool writes; write_file/delete_file consume the raw
122
- // path, so their snapshot must too.
123
98
  export const PATH_REPAIRING_EDIT_TOOLS = new Set([
124
99
  'str_replace',
125
100
  'patch_file',
126
101
  'replace_document_text',
127
102
  ]);
128
- // Repair a model/user-supplied path to a real on-disk file WITHOUT changing
129
- // intent, for tools that act on a file expected to already exist. Literal
130
- // first: any path that already resolves — including one that legitimately
131
- // contains quotes or backslashes — is returned untouched. Only when the path
132
- // does not resolve do we try safe de-manglings: strip surrounding quotes,
133
- // collapse doubled backslashes, and finally match a directory entry that
134
- // differs only by foldable punctuation (the "model flattened a curly ’ to a
135
- // straight '" case, which no transform of the input can reproduce). Returns the
136
- // input unchanged when nothing better exists, so the caller's normal not-found
137
- // handling (and its recovery hint) still fires.
138
103
  export function repairFilePath(rootDir, raw) {
139
104
  if (!raw || existsAgainst(rootDir, raw))
140
105
  return raw;
@@ -164,12 +129,10 @@ export function repairFilePath(rootDir, raw) {
164
129
  }
165
130
  const matches = entries.filter((entry) => foldPunctuation(entry) === wanted);
166
131
  if (matches.length !== 1)
167
- return raw; // none, or ambiguous — never guess
132
+ return raw;
168
133
  const matchedAbs = path.join(directory, matches[0]);
169
134
  const matchedRel = path.relative(rootDir, matchedAbs);
170
135
  const matched = path.isAbsolute(dequoted) ? matchedAbs : matchedRel || matchedAbs;
171
- // Never auto-resolve into a protected file: a fold-match that lands on `.env`
172
- // would confirm its existence to the model and bypass redaction.
173
136
  if (isProtectedRepairTarget(rootDir, matched))
174
137
  return raw;
175
138
  return matched;
@@ -68,10 +68,6 @@ export async function readFile(context, args) {
68
68
  }
69
69
  }
70
70
  const previewPath = projectPath ?? filePath;
71
- // A clean dotenv file is shown with keys visible and values tokenized so the
72
- // agent can still edit it (str_replace/write_file round-trip the tokens) and
73
- // read coverage is recorded. Any other secret file — PEM, JSON credentials,
74
- // or a dotenv with a stray non-assignment line — keeps the opaque blackout.
75
71
  const editableDotenv = Boolean(projectPath) &&
76
72
  Boolean(safety) &&
77
73
  isDotenvLikePath(previewPath) &&
@@ -95,10 +95,6 @@ export async function replaceDocumentText(context, args) {
95
95
  failureCategory: 'invalid_argument',
96
96
  };
97
97
  }
98
- // outputPath is a write target, not an existing input, so it must NOT be
99
- // path-repaired: fold-match could redirect a "create Review '24.docx" onto an
100
- // existing Review ’24.docx and overwrite it. The executor mirrors this by not
101
- // repairing the snapshot path when an outputPath is present.
102
98
  const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
103
99
  const targetPath = outputRaw
104
100
  ? relativeEditablePath(context.rootDir, outputRaw)
@@ -153,9 +149,6 @@ export async function replaceDocumentText(context, args) {
153
149
  failureCategory: serverResult.failureCategory ?? 'external_service',
154
150
  };
155
151
  }
156
- // Validate-only: report per-replacement match info without touching the file.
157
- // changed:false marks it non-mutating so the agent loop does not count a
158
- // dry-run as an applied edit.
159
152
  if (validateOnly) {
160
153
  return {
161
154
  ok: true,
@@ -166,8 +159,6 @@ export async function replaceDocumentText(context, args) {
166
159
  results: serverResult.results,
167
160
  };
168
161
  }
169
- // No replacement matched: nothing was written. Surface per-item reasons so
170
- // the model can correct and resend only the failing entries.
171
162
  const replacementCount = Number(serverResult.replacementCount ?? 0);
172
163
  if (replacementCount === 0) {
173
164
  const failures = Array.isArray(serverResult.replacements)
@@ -224,9 +215,6 @@ export async function replaceDocumentText(context, args) {
224
215
  failedCount: serverResult.failedCount,
225
216
  replacements: serverResult.replacements,
226
217
  bytesWritten: nextData.length,
227
- // A partial batch still wrote the matched entries (changed:true above), but
228
- // the loop must reflect and repair the missed entries — needsRepair forces
229
- // that without losing credit for the applied edits.
230
218
  ...(failedCount > 0
231
219
  ? {
232
220
  needsRepair: true,
@@ -45,7 +45,7 @@ export async function runShellCommand(context, args) {
45
45
  ok: false,
46
46
  skipped: true,
47
47
  command,
48
- error: 'User declined command execution',
48
+ error: 'User declined command execution. Do not rerun this command or try a broader variant of it; either continue without it or ask the user one specific question about how to proceed.',
49
49
  };
50
50
  }
51
51
  }
@@ -67,8 +67,6 @@ export async function runShellCommand(context, args) {
67
67
  if (repoSync.added || repoSync.modified || repoSync.removed) {
68
68
  onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
69
69
  }
70
- // Redact connection-string passwords so shell output (e.g. `cat .env`,
71
- // `printenv`) cannot leak them into history or telemetry.
72
70
  let output = typeof result.output === 'string'
73
71
  ? redactConnectionStringCredentials(result.output)
74
72
  : result.output;
@@ -3,6 +3,7 @@ import { execFileSync, spawn } from 'node:child_process';
3
3
  import { syncIndexFromDisk } from '../project-index.js';
4
4
  import { isTuiMode } from '../runtime-mode.js';
5
5
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
6
+ import { ensureSessionScratchDir } from '../scratch-dir.js';
6
7
  const DEFAULT_TIMEOUT = 5 * 60 * 1000;
7
8
  const MAX_OUTPUT_CHARS = 4000;
8
9
  const MAX_CAPTURE_CHARS = 1024 * 1024;
@@ -99,6 +100,7 @@ function executeNodeScript(rootDir, script, timeout) {
99
100
  npm_config_progress: 'false',
100
101
  npm_config_fund: 'false',
101
102
  npm_config_audit: 'false',
103
+ THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
102
104
  },
103
105
  stdio: ['pipe', 'pipe', 'pipe'],
104
106
  });
@@ -6,12 +6,6 @@ import { addSignatureForNode } from './extractors/index.js';
6
6
  import { getRepoMapLanguageForFile, } from './repo-map-languages.js';
7
7
  const require = createRequire(import.meta.url);
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- // web-tree-sitter is vendored (see vendor/web-tree-sitter/NOTICE) so the
10
- // published package has zero runtime dependencies. Resolve the vendored CommonJS
11
- // runtime relative to this compiled file: dist/src/ -> dist/vendor in the
12
- // published layout, with the source tree as a dev fallback. The .cjs locates its
13
- // own web-tree-sitter.wasm next to itself via __dirname, so no locateFile
14
- // override is needed.
15
9
  function resolveVendoredTreeSitter() {
16
10
  const candidates = [
17
11
  path.resolve(__dirname, '..', 'vendor', 'web-tree-sitter', 'web-tree-sitter.cjs'),
@@ -998,8 +998,6 @@ export function getInputCommandToken(input) {
998
998
  return '';
999
999
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
1000
  const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1001
- // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
1002
- // not a slash command — no command contains a slash, so don't treat it as one.
1003
1001
  if (token.indexOf('/', 1) !== -1)
1004
1002
  return '';
1005
1003
  return token;
@@ -1012,9 +1010,6 @@ function shouldShowCommandPalette(state) {
1012
1010
  !state.resumePickerOpen &&
1013
1011
  trimmed.startsWith('/') &&
1014
1012
  !trimmed.includes(' ') &&
1015
- // A '/'-prefixed token with a second '/' is a filesystem path, not a
1016
- // command — getInputCommandToken returns '' for it, so the palette stays
1017
- // closed when a folder path is pasted.
1018
1013
  getInputCommandToken(trimmed) !== '');
1019
1014
  }
1020
1015
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
@@ -1405,9 +1400,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1405
1400
  activeTurnAbort?.abort();
1406
1401
  activeTurnAbort = null;
1407
1402
  cancelActiveCommand();
1408
- // Ctrl+C with a queued message: recall it into the composer for editing
1409
- // rather than auto-submitting it against the cancelled turn. (Esc with a
1410
- // queued message clears the slot in shell-input without cancelling.)
1411
1403
  const queued = store.getState().queuedMessage;
1412
1404
  const cancelledEntries = [
1413
1405
  ...takePendingTurnEntries(),
@@ -1811,8 +1803,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1811
1803
  if (!queued || store.getState().busy || exiting) {
1812
1804
  return;
1813
1805
  }
1814
- // Rehydrate attachments/chunks before submit: handleSubmit expands
1815
- // pastedChunks from the store and the turn picks up imageAttachments.
1816
1806
  store.update((current) => ({
1817
1807
  ...current,
1818
1808
  imageAttachments: queued.imageAttachments,
@@ -1839,9 +1829,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1839
1829
  });
1840
1830
  return;
1841
1831
  }
1842
- // Snapshot the raw (placeholder) body plus chunks/images so recall and
1843
- // flush round-trip the collapsed paste + attachments. Hold at most one;
1844
- // a second enqueue replaces the slot.
1845
1832
  const pending = store.getState();
1846
1833
  const snapshot = {
1847
1834
  body: String(rawInput ?? ''),
@@ -2094,8 +2081,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2094
2081
  appendError(error.message);
2095
2082
  }
2096
2083
  await remountTui();
2097
- // A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
2098
- // cancelActiveTurn); only a genuine error flushes a pending message.
2099
2084
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2100
2085
  await flushQueuedMessage();
2101
2086
  }
@@ -91,14 +91,11 @@ function normalizeChildMessage(raw) {
91
91
  export function resolveTuiBinaryPath() {
92
92
  const binaryName = process.platform === 'win32' ? 'thegitai-tui.exe' : 'thegitai-tui';
93
93
  const platformPackage = `@thegitai/tui-${process.platform}-${process.arch}`;
94
- // 1) Published per-platform optional dependency (the installed-from-npm path).
95
94
  try {
96
95
  return requireFromHere.resolve(`${platformPackage}/${binaryName}`);
97
96
  }
98
97
  catch {
99
- // Not installed (unsupported platform yet, or local dev) — fall through.
100
98
  }
101
- // 2) Local dev build: `npm run build:tui` populates the workspace bin/.
102
99
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
103
100
  const devCandidates = [
104
101
  path.join(moduleDir, '../../../bin', binaryName),
@@ -137,7 +134,6 @@ export function createRatatuiBridge() {
137
134
  }
138
135
  }
139
136
  catch {
140
- // ignore malformed protocol lines
141
137
  }
142
138
  });
143
139
  child.on('exit', () => {
@@ -131,9 +131,6 @@ function diffLinePrefix(kind) {
131
131
  return ' ';
132
132
  }
133
133
  }
134
- // Fit a single diff line to the available terminal width with a clean ellipsis.
135
- // Unlike the general-purpose truncate(), this never appends a word like
136
- // "(truncated)" — that wording belongs on tool output, not on-screen diff rows.
137
134
  function fitDiffLine(content, maxWidth) {
138
135
  if (maxWidth <= 0)
139
136
  return '';
@@ -178,8 +175,6 @@ function getInputCommandToken(input) {
178
175
  const trimmed = String(input ?? '').trimStart();
179
176
  const match = trimmed.match(/^\/[^\s]*/);
180
177
  const token = match?.[0] ?? '';
181
- // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
182
- // not a slash command — no command contains a slash, so don't treat it as one.
183
178
  if (token.indexOf('/', 1) !== -1)
184
179
  return '';
185
180
  return token;
@@ -689,9 +684,6 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
689
684
  sections.push({ kind: 'live', lines: liveLines });
690
685
  }
691
686
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
692
- // Composer stays visible while busy unless a blocking overlay is active. When
693
- // one message is already queued, the queued chip is shown here in place of the
694
- // input box (only one message is ever held) so there is no empty prompt.
695
687
  if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
696
688
  const composerLines = [];
697
689
  if (state.queuedMessage) {
@@ -275,8 +275,6 @@ export function handleShellKeyEvent(store, handlers, event) {
275
275
  }
276
276
  if (key.escape) {
277
277
  if (state.busy) {
278
- // With a queued message, Esc clears the queue only (turn keeps running).
279
- // With nothing queued, Esc cancels the turn (Ctrl+C also cancels).
280
278
  if (state.queuedMessage) {
281
279
  handlers.onLiveFrameShapeChange();
282
280
  store.update((current) => ({ ...current, queuedMessage: null }));
@@ -317,8 +315,6 @@ export function handleShellKeyEvent(store, handlers, event) {
317
315
  return;
318
316
  }
319
317
  if (key.upArrow) {
320
- // While busy with an empty composer, Up recalls and dequeues the queued
321
- // message for editing; otherwise it walks prompt history as usual.
322
318
  if (state.busy && state.input.trim() === '' && state.queuedMessage) {
323
319
  handlers.onLiveFrameShapeChange();
324
320
  store.update((current) => {
@@ -3,11 +3,6 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  const PACKAGE_NAME = '@thegitai/cli';
5
5
  const UNKNOWN_VERSION = '0.0.0';
6
- // Resolve the package version at runtime by walking up from this module to the
7
- // nearest package.json named @thegitai/cli. This works in both layouts: the
8
- // compiled binary (dist/bin/ai.js → ../../package.json) and the source tree run
9
- // under tsx in tests (src/version.ts → ../package.json). The name guard avoids
10
- // picking up an unrelated manifest if the file is ever nested elsewhere.
11
6
  export function getCliVersion() {
12
7
  let dir = path.dirname(fileURLToPath(import.meta.url));
13
8
  for (let depth = 0; depth < 6; depth++) {
@@ -18,7 +13,6 @@ export function getCliVersion() {
18
13
  }
19
14
  }
20
15
  catch {
21
- // No package.json at this level (or unreadable); keep walking up.
22
16
  }
23
17
  const parent = path.dirname(dir);
24
18
  if (parent === dir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.16",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -25,10 +25,10 @@
25
25
  "@lydell/node-pty-linux-x64": "1.1.0",
26
26
  "@lydell/node-pty-win32-arm64": "1.1.0",
27
27
  "@lydell/node-pty-win32-x64": "1.1.0",
28
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.14",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.14",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.14",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.14",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.16",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.16",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.16",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.16",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {