@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10

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 (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  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 +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
@@ -0,0 +1,75 @@
1
+ import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const scratchDirs = new Map();
5
+ let activeSessionId = 'default';
6
+ export function setScratchSession(sessionId) {
7
+ activeSessionId = String(sessionId ?? '').trim() || 'default';
8
+ }
9
+ function allocateSessionScratchDir() {
10
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
11
+ scratchDirs.set(activeSessionId, dir);
12
+ return dir;
13
+ }
14
+ export function sessionScratchDir() {
15
+ return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
16
+ }
17
+ function isOwnedDirectory(dir) {
18
+ try {
19
+ const st = lstatSync(dir);
20
+ if (st.isSymbolicLink() || !st.isDirectory())
21
+ return false;
22
+ if (process.platform !== 'win32' &&
23
+ typeof process.getuid === 'function' &&
24
+ st.uid !== process.getuid()) {
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export function ensureSessionScratchDir() {
34
+ let dir = sessionScratchDir();
35
+ if (!isOwnedDirectory(dir)) {
36
+ dir = allocateSessionScratchDir();
37
+ }
38
+ if (process.platform !== 'win32') {
39
+ chmodSync(dir, 0o700);
40
+ }
41
+ return dir;
42
+ }
43
+ function hasUnsafeScratchComponent(root, relativePath) {
44
+ let current = root;
45
+ for (const segment of relativePath.split(path.sep).filter(Boolean)) {
46
+ current = path.join(current, segment);
47
+ try {
48
+ const stat = lstatSync(current);
49
+ if (stat.isSymbolicLink())
50
+ return true;
51
+ if (stat.isDirectory())
52
+ continue;
53
+ if (!stat.isFile() || stat.nlink > 1)
54
+ return true;
55
+ }
56
+ catch (error) {
57
+ return error?.code !== 'ENOENT';
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+ export function isWithinSessionScratchDir(absPath) {
63
+ const root = path.resolve(ensureSessionScratchDir());
64
+ const resolved = path.resolve(absPath);
65
+ const relative = path.relative(root, resolved);
66
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
67
+ }
68
+ export function isInsideTheGitAiScratch(absPath) {
69
+ const root = path.resolve(ensureSessionScratchDir());
70
+ const resolved = path.resolve(absPath);
71
+ const relative = path.relative(root, resolved);
72
+ if (!isWithinSessionScratchDir(resolved))
73
+ return false;
74
+ return !hasUnsafeScratchComponent(root, relative);
75
+ }
@@ -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')) {
@@ -1,12 +1,13 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { getClientStateDir } from './client-state.js';
5
6
  import { normalizeAssistantEditJournal } from './edit-journal.js';
6
7
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
7
- import { truncate } from './utils.js';
8
+ import { singleLinePreview } from './utils.js';
8
9
  const SESSION_STORE_VERSION = 1;
9
- const MAX_RECENT_SESSIONS = 5;
10
+ export const MAX_RECENT_SESSIONS = 10;
10
11
  function cloneJson(value) {
11
12
  return JSON.parse(JSON.stringify(value ?? null));
12
13
  }
@@ -77,6 +78,12 @@ function listSessionFiles(rootDir, env = process.env) {
77
78
  .filter((name) => name.endsWith('.json'))
78
79
  .map((name) => path.join(dir, name));
79
80
  }
81
+ function normalizeBranch(value) {
82
+ const text = String(value ?? '')
83
+ .replace(/[\r\n\t]/g, ' ')
84
+ .trim();
85
+ return text ? text.slice(0, 120) : null;
86
+ }
80
87
  function normalizeHistory(value) {
81
88
  if (!Array.isArray(value))
82
89
  return [];
@@ -107,6 +114,7 @@ function normalizeSnapshot(raw, rootDir) {
107
114
  createdAt: normalizeIsoDate(raw.createdAt),
108
115
  updatedAt: normalizeIsoDate(raw.updatedAt),
109
116
  modelId,
117
+ branch: normalizeBranch(raw.branch),
110
118
  history: cloneJson(normalizeHistory(raw.history)),
111
119
  clientState: sanitizeClientState(raw.clientState),
112
120
  serverState: sanitizeOpaqueState(raw.serverState),
@@ -122,7 +130,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
122
130
  snapshots.push(loadSnapshotFile(filePath, rootDir));
123
131
  }
124
132
  catch {
125
- // Skip corrupted snapshots silently — customers have no actionable debug path here.
126
133
  }
127
134
  }
128
135
  return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -155,6 +162,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
155
162
  }
156
163
  }
157
164
  }
165
+ export function readGitBranch(rootDir) {
166
+ try {
167
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
168
+ cwd: rootDir,
169
+ encoding: 'utf8',
170
+ timeout: 1500,
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ });
173
+ const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
174
+ return branch && branch !== 'HEAD' ? branch : null;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
158
180
  export function snapshotFromSession(session) {
159
181
  const now = new Date().toISOString();
160
182
  const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
@@ -169,6 +191,7 @@ export function snapshotFromSession(session) {
169
191
  createdAt,
170
192
  updatedAt: now,
171
193
  modelId: session.modelId,
194
+ branch: readGitBranch(session.rootDir),
172
195
  history: cloneJson(session.history),
173
196
  clientState: {
174
197
  editCounter: session.clientState.editCounter,
@@ -211,6 +234,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
211
234
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
212
235
  };
213
236
  }
237
+ export function sessionHasUserMessage(session) {
238
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
239
+ }
214
240
  function extractMarkedSection(text, marker) {
215
241
  const index = text.indexOf(marker);
216
242
  if (index === -1)
@@ -219,23 +245,27 @@ function extractMarkedSection(text, marker) {
219
245
  const end = after.indexOf('\n\n');
220
246
  return (end === -1 ? after : after.slice(0, end)).trim();
221
247
  }
222
- function extractLastUserMessage(history) {
223
- for (let i = history.length - 1; i >= 0; i--) {
224
- const entry = history[i];
225
- if (!entry || entry.role !== 'user')
226
- continue;
227
- const text = (entry.parts ?? [])
228
- .map((part) => (typeof part?.text === 'string' ? part.text : ''))
229
- .filter(Boolean)
230
- .join('\n')
231
- .trim();
232
- if (!text)
233
- continue;
234
- const request = extractMarkedSection(text, 'Current user request:') ||
235
- extractMarkedSection(text, 'User request:');
236
- const message = extractMarkedSection(text, 'Current user message:') ||
237
- extractMarkedSection(text, 'User message:');
238
- return truncate(request || message || text, 120);
248
+ export function userPromptText(entry) {
249
+ if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
250
+ return '';
251
+ const text = (entry.parts ?? [])
252
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
253
+ .filter(Boolean)
254
+ .join('\n')
255
+ .trim();
256
+ if (!text)
257
+ return '';
258
+ const request = extractMarkedSection(text, 'Current user request:') ||
259
+ extractMarkedSection(text, 'User request:');
260
+ const message = extractMarkedSection(text, 'Current user message:') ||
261
+ extractMarkedSection(text, 'User message:');
262
+ return (request || message || text).trim();
263
+ }
264
+ function extractFirstUserPrompt(history) {
265
+ for (const entry of history) {
266
+ const text = userPromptText(entry);
267
+ if (text)
268
+ return singleLinePreview(text, 120);
239
269
  }
240
270
  return '';
241
271
  }
@@ -248,8 +278,9 @@ function metadataFromSnapshot(snapshot) {
248
278
  updatedAt: snapshot.updatedAt,
249
279
  modelId: snapshot.modelId,
250
280
  messageCount: snapshot.history.length,
251
- lastUserMessage: extractLastUserMessage(snapshot.history),
281
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
252
282
  summaryPreview: '',
283
+ branch: snapshot.branch ?? null,
253
284
  };
254
285
  }
255
286
  export function listSessionMetadata(rootDir, env = process.env) {
@@ -69,6 +69,14 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
69
69
  serverState: cloneOpaqueState(serverState),
70
70
  };
71
71
  }
72
+ export function startNewConversation(session) {
73
+ clearConversation(session);
74
+ const createdAt = new Date().toISOString();
75
+ session.sessionId = createSessionId();
76
+ session.sessionName = null;
77
+ session.sessionCreatedAt = createdAt;
78
+ session.sessionUpdatedAt = createdAt;
79
+ }
72
80
  export function clearConversation(session) {
73
81
  session.history = [];
74
82
  session.serverState = preserveProviderSelection(session.serverState);
@@ -0,0 +1,106 @@
1
+ export const MAX_TODO_ITEMS = 20;
2
+ export const MAX_TODO_TEXT_CHARS = 160;
3
+ let items = [];
4
+ let activeSessionId = null;
5
+ const STATUS_ALIASES = {
6
+ pending: 'pending',
7
+ todo: 'pending',
8
+ not_started: 'pending',
9
+ in_progress: 'in_progress',
10
+ active: 'in_progress',
11
+ doing: 'in_progress',
12
+ completed: 'completed',
13
+ complete: 'completed',
14
+ done: 'completed',
15
+ };
16
+ function normalizeStatus(raw) {
17
+ const key = String(raw ?? '')
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[-\s]+/g, '_');
21
+ return STATUS_ALIASES[key] ?? null;
22
+ }
23
+ export function isCompletedStatus(raw) {
24
+ return normalizeStatus(raw) === 'completed';
25
+ }
26
+ const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
27
+ export function extractTodosArg(args) {
28
+ if (!args || typeof args !== 'object')
29
+ return undefined;
30
+ const record = args;
31
+ if (record.todos !== undefined)
32
+ return record.todos;
33
+ for (const key of TODOS_ARG_ALIASES) {
34
+ if (record[key] !== undefined)
35
+ return record[key];
36
+ }
37
+ return undefined;
38
+ }
39
+ export function setTodoSession(sessionId) {
40
+ const next = String(sessionId ?? '').trim() || null;
41
+ if (activeSessionId !== next) {
42
+ items = [];
43
+ }
44
+ activeSessionId = next;
45
+ }
46
+ export function listTodos() {
47
+ return items.map((item) => ({ ...item }));
48
+ }
49
+ export function getTodoSnapshot() {
50
+ return {
51
+ items: listTodos(),
52
+ completedCount: items.filter((item) => item.status === 'completed').length,
53
+ totalCount: items.length,
54
+ };
55
+ }
56
+ export function clearTodos() {
57
+ items = [];
58
+ }
59
+ export function replaceTodos(raw) {
60
+ if (!Array.isArray(raw)) {
61
+ return { ok: false, error: 'todos must be an array of { text, status } items.' };
62
+ }
63
+ const normalizations = [];
64
+ if (raw.length > MAX_TODO_ITEMS) {
65
+ return {
66
+ ok: false,
67
+ error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
68
+ };
69
+ }
70
+ const next = [];
71
+ let sawInProgress = false;
72
+ for (const [index, entry] of raw.entries()) {
73
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
74
+ return { ok: false, error: `todos[${index}] must be an object with text and status.` };
75
+ }
76
+ const text = String(entry.text ?? '')
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ if (!text) {
80
+ return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
81
+ }
82
+ const status = normalizeStatus(entry.status);
83
+ if (!status) {
84
+ return {
85
+ ok: false,
86
+ error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
87
+ };
88
+ }
89
+ let boundedText = text;
90
+ if (boundedText.length > MAX_TODO_TEXT_CHARS) {
91
+ boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
92
+ normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
93
+ }
94
+ let finalStatus = status;
95
+ if (status === 'in_progress') {
96
+ if (sawInProgress) {
97
+ finalStatus = 'pending';
98
+ normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
99
+ }
100
+ sawInProgress = true;
101
+ }
102
+ next.push({ text: boundedText, status: finalStatus });
103
+ }
104
+ items = next;
105
+ return { ok: true, snapshot: getTodoSnapshot(), normalizations };
106
+ }
@@ -1,8 +1,13 @@
1
+ import { drainBackgroundJobNotifications, getBackgroundJob, } from './background-jobs.js';
1
2
  import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_RECORDS, operationFromSnapshots, readFileEditSnapshot, } from './edit-journal.js';
2
3
  import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
3
4
  import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
5
+ import { classifyProjectPath } from './patcher.js';
4
6
  import { dispatchTool } from './tools/index.js';
7
+ import { syncIndexFromDisk } from './project-index.js';
8
+ import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
5
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
10
+ import { extractTodosArg } from './todo-list.js';
6
11
  const EDIT_FILE_PATH_ARG_ALIASES = [
7
12
  'filePath',
8
13
  'file_path',
@@ -11,6 +16,7 @@ const EDIT_FILE_PATH_ARG_ALIASES = [
11
16
  'file',
12
17
  'filename',
13
18
  ];
19
+ const backgroundCommandTrackers = new Map();
14
20
  function toolCallSummary(call) {
15
21
  const args = call.args && typeof call.args === 'object' ? call.args : {};
16
22
  if (call.name === 'run_command') {
@@ -19,6 +25,17 @@ function toolCallSummary(call) {
19
25
  if (call.name === 'run_node_script') {
20
26
  return String(args.script ?? '').trim().slice(0, 120);
21
27
  }
28
+ if (call.name === 'shell_job_output' ||
29
+ call.name === 'shell_job_kill') {
30
+ return String(args.job_id ?? '').trim();
31
+ }
32
+ if (call.name === 'update_todos') {
33
+ const raw = extractTodosArg(args);
34
+ const todos = Array.isArray(raw) ? raw : [];
35
+ if (todos.length === 0)
36
+ return '';
37
+ return `${todos.length} item${todos.length === 1 ? '' : 's'}`;
38
+ }
22
39
  const filePath = getEditToolFilePath(call);
23
40
  if (filePath)
24
41
  return filePath;
@@ -45,8 +62,125 @@ function getEditToolFilePath(call) {
45
62
  }
46
63
  return '';
47
64
  }
65
+ function editToolWritesSeparateOutput(call) {
66
+ if (call.name !== 'replace_document_text')
67
+ return false;
68
+ const args = call.args && typeof call.args === 'object' ? call.args : {};
69
+ const output = args.outputPath ?? args.output_path;
70
+ return typeof output === 'string' && output.trim().length > 0;
71
+ }
72
+ async function collectTrackedCommandMutations({ session, projectIndex, result, tracker, toolName, toolCallId, turnId, }) {
73
+ const checkpoint = ensureActiveCheckpoint(session.clientState.safety, turnId);
74
+ const records = collectCommandMutations({
75
+ state: session.clientState.safety,
76
+ rootDir: session.rootDir,
77
+ tracker,
78
+ toolName,
79
+ toolCallId,
80
+ turnId,
81
+ checkpointId: checkpoint.id,
82
+ });
83
+ if (!records.length)
84
+ return;
85
+ invalidateShellDiagnosticsCache(session.rootDir);
86
+ rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
87
+ const priorSync = result.repoSync;
88
+ if (!(priorSync && (priorSync.added || priorSync.modified || priorSync.removed))) {
89
+ const mutationCounts = {
90
+ added: records.filter((record) => record.operation === 'create').length,
91
+ modified: records.filter((record) => record.operation === 'update').length,
92
+ removed: records.filter((record) => record.operation === 'delete').length,
93
+ indexedChunks: 0,
94
+ retrievalTokensUsed: 0,
95
+ };
96
+ if (priorSync?.error) {
97
+ result.repoSync = {
98
+ ...mutationCounts,
99
+ indexSyncError: priorSync.error,
100
+ skipped: true,
101
+ reason: 'local index sync failed after command execution',
102
+ };
103
+ }
104
+ else {
105
+ try {
106
+ const repoSync = await syncIndexFromDisk(projectIndex);
107
+ result.repoSync = {
108
+ ...repoSync,
109
+ added: Math.max(repoSync.added, mutationCounts.added),
110
+ modified: Math.max(repoSync.modified, mutationCounts.modified),
111
+ removed: Math.max(repoSync.removed, mutationCounts.removed),
112
+ };
113
+ }
114
+ catch (error) {
115
+ const indexSyncError = error instanceof Error ? error.message : String(error);
116
+ result.repoSync = { ...mutationCounts, indexSyncError };
117
+ session.onStatus(`Repository changes were recorded, but local index sync failed: ${indexSyncError}`);
118
+ }
119
+ }
120
+ }
121
+ result.sessionEdits = records.map((record) => ({
122
+ id: record.id,
123
+ filePath: record.filePath,
124
+ operation: record.operation,
125
+ beforeHash: record.beforeHash,
126
+ afterHash: record.afterHash,
127
+ }));
128
+ result.diagnostics = runShellDiagnostics(session.rootDir);
129
+ }
130
+ export async function collectBackgroundJobUiKillMutations({ session, projectIndex, jobId, result, }) {
131
+ const normalizedJobId = String(jobId ?? result.snapshot?.id ?? '').trim();
132
+ if (!normalizedJobId || !result.ok)
133
+ return;
134
+ const tracked = backgroundCommandTrackers.get(normalizedJobId);
135
+ if (!tracked)
136
+ return;
137
+ const mutationResult = result;
138
+ await collectTrackedCommandMutations({
139
+ session,
140
+ projectIndex,
141
+ result: mutationResult,
142
+ tracker: tracked.tracker,
143
+ toolName: tracked.toolName,
144
+ toolCallId: tracked.toolCallId,
145
+ turnId: tracked.turnId,
146
+ });
147
+ if (result.snapshot?.status === 'running') {
148
+ tracked.tracker = captureMutationBaseline(session.rootDir);
149
+ }
150
+ else {
151
+ backgroundCommandTrackers.delete(normalizedJobId);
152
+ }
153
+ }
154
+ export async function collectBackgroundJobUiOutputMutations({ session, projectIndex, jobId, }) {
155
+ const normalizedJobId = String(jobId ?? '').trim();
156
+ if (!normalizedJobId)
157
+ return;
158
+ const tracked = backgroundCommandTrackers.get(normalizedJobId);
159
+ if (!tracked)
160
+ return;
161
+ const snapshot = getBackgroundJob(normalizedJobId, {
162
+ sessionId: session.sessionId,
163
+ });
164
+ if (!snapshot)
165
+ return;
166
+ await collectTrackedCommandMutations({
167
+ session,
168
+ projectIndex,
169
+ result: {},
170
+ tracker: tracked.tracker,
171
+ toolName: tracked.toolName,
172
+ toolCallId: tracked.toolCallId,
173
+ turnId: tracked.turnId,
174
+ });
175
+ if (snapshot.status === 'running') {
176
+ tracked.tracker = captureMutationBaseline(session.rootDir);
177
+ }
178
+ else {
179
+ backgroundCommandTrackers.delete(normalizedJobId);
180
+ }
181
+ }
48
182
  function recordAssistantEdit(session, call, result, before) {
49
- if (!before || !isEditToolName(call.name))
183
+ if (!before || !isEditToolName(call.name) || result?.scratch === true)
50
184
  return;
51
185
  if (!result || typeof result !== 'object' || result.ok !== true) {
52
186
  const filePath = getEditToolFilePath(call);
@@ -119,11 +253,20 @@ export async function executeLocalToolCall(toolContext, session, call) {
119
253
  session.onToolEvent?.({ call, result });
120
254
  return result;
121
255
  }
122
- const filePathBeforeEdit = isEditToolName(call.name) ? getEditToolFilePath(call) : '';
123
- if (filePathBeforeEdit) {
256
+ const rawEditFilePath = isEditToolName(call.name)
257
+ ? getEditToolFilePath(call)
258
+ : '';
259
+ const filePathBeforeEdit = rawEditFilePath &&
260
+ PATH_REPAIRING_EDIT_TOOLS.has(call.name) &&
261
+ !editToolWritesSeparateOutput(call)
262
+ ? repairFilePath(session.rootDir, rawEditFilePath)
263
+ : rawEditFilePath;
264
+ const tracksRepositoryEdit = filePathBeforeEdit &&
265
+ classifyProjectPath(session.rootDir, filePathBeforeEdit) === 'project';
266
+ if (tracksRepositoryEdit) {
124
267
  rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
125
268
  }
126
- const beforeEditSnapshot = filePathBeforeEdit
269
+ const beforeEditSnapshot = tracksRepositoryEdit
127
270
  ? readFileEditSnapshot(session.rootDir, filePathBeforeEdit)
128
271
  : null;
129
272
  const commandTracker = call.name === 'run_command' || call.name === 'run_node_script'
@@ -131,6 +274,7 @@ export async function executeLocalToolCall(toolContext, session, call) {
131
274
  : null;
132
275
  const context = {
133
276
  rootDir: session.rootDir,
277
+ sessionId: session.sessionId,
134
278
  projectIndex: toolContext.projectIndex,
135
279
  autoYes: session.autoYes,
136
280
  confirmCommand: session.confirmCommand,
@@ -152,28 +296,57 @@ export async function executeLocalToolCall(toolContext, session, call) {
152
296
  };
153
297
  const result = await dispatchTool(context, call);
154
298
  recordAssistantEdit(session, call, result, beforeEditSnapshot);
155
- if (result && commandTracker && (call.name === 'run_command' || call.name === 'run_node_script')) {
156
- const checkpoint = ensureActiveCheckpoint(session.clientState.safety, session.turnState.id);
157
- const records = collectCommandMutations({
158
- state: session.clientState.safety,
159
- rootDir: session.rootDir,
299
+ if (result && typeof result === 'object' && commandTracker) {
300
+ await collectTrackedCommandMutations({
301
+ session,
302
+ projectIndex: toolContext.projectIndex,
303
+ result,
160
304
  tracker: commandTracker,
161
305
  toolName: call.name,
162
306
  toolCallId: call.id,
163
307
  turnId: session.turnState.id,
164
- checkpointId: checkpoint.id,
165
308
  });
166
- if (records.length) {
167
- invalidateShellDiagnosticsCache(session.rootDir);
168
- rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), session.turnState.id);
169
- result.sessionEdits = records.map((record) => ({
170
- id: record.id,
171
- filePath: record.filePath,
172
- operation: record.operation,
173
- beforeHash: record.beforeHash,
174
- afterHash: record.afterHash,
175
- }));
176
- result.diagnostics = runShellDiagnostics(session.rootDir);
309
+ if (call.name === 'run_command' &&
310
+ result.backgrounded === true &&
311
+ result.status === 'running' &&
312
+ result.jobId) {
313
+ backgroundCommandTrackers.set(String(result.jobId), {
314
+ tracker: captureMutationBaseline(session.rootDir),
315
+ toolName: call.name,
316
+ toolCallId: call.id,
317
+ turnId: session.turnState.id,
318
+ });
319
+ }
320
+ }
321
+ if (result &&
322
+ typeof result === 'object' &&
323
+ (call.name === 'shell_job_output' || call.name === 'shell_job_kill')) {
324
+ const jobId = String(result.jobId ?? call.args?.job_id ?? '').trim();
325
+ const tracked = backgroundCommandTrackers.get(jobId);
326
+ if (tracked) {
327
+ await collectTrackedCommandMutations({
328
+ session,
329
+ projectIndex: toolContext.projectIndex,
330
+ result,
331
+ tracker: tracked.tracker,
332
+ toolName: tracked.toolName,
333
+ toolCallId: tracked.toolCallId,
334
+ turnId: tracked.turnId,
335
+ });
336
+ if (result.status === 'running') {
337
+ tracked.tracker = captureMutationBaseline(session.rootDir);
338
+ }
339
+ else {
340
+ backgroundCommandTrackers.delete(jobId);
341
+ }
342
+ }
343
+ }
344
+ if (result && typeof result === 'object') {
345
+ const backgroundJobUpdate = drainBackgroundJobNotifications({
346
+ sessionId: session.sessionId,
347
+ });
348
+ if (backgroundJobUpdate) {
349
+ result.backgroundJobUpdate = backgroundJobUpdate;
177
350
  }
178
351
  }
179
352
  session.onToolEvent?.({ call, result });