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

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 (56) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +138 -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 +59 -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 +97 -12
  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 +0 -1
  27. package/dist/src/todo-list.js +106 -0
  28. package/dist/src/tool-executor.js +194 -21
  29. package/dist/src/tools/delete-file.js +23 -5
  30. package/dist/src/tools/index.js +6 -0
  31. package/dist/src/tools/patch-file.js +33 -7
  32. package/dist/src/tools/path-suggest.js +81 -8
  33. package/dist/src/tools/read-document.js +2 -2
  34. package/dist/src/tools/read-file.js +17 -8
  35. package/dist/src/tools/replace-document-text.js +10 -12
  36. package/dist/src/tools/restore-checkpoint.js +1 -1
  37. package/dist/src/tools/run-command.js +109 -24
  38. package/dist/src/tools/run-node-script.js +27 -5
  39. package/dist/src/tools/shell-job-kill.js +48 -0
  40. package/dist/src/tools/shell-job-output.js +51 -0
  41. package/dist/src/tools/str-replace.js +33 -7
  42. package/dist/src/tools/undo-edit.js +1 -1
  43. package/dist/src/tools/update-todos.js +27 -0
  44. package/dist/src/tools/write-file.js +26 -6
  45. package/dist/src/tree-sitter-runtime.js +8 -1
  46. package/dist/src/ui/repl.js +342 -23
  47. package/dist/src/ui/tui/bridge.js +0 -4
  48. package/dist/src/ui/tui/build-frame.js +220 -24
  49. package/dist/src/ui/tui/shell-input.js +33 -4
  50. package/dist/src/ui/tui/terminal-title.js +81 -0
  51. package/dist/src/version.js +0 -6
  52. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  53. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  55. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  56. package/package.json +14 -15
@@ -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));
@@ -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 });
@@ -1,5 +1,5 @@
1
- import chalk from 'chalk';
2
- import { deleteProjectFile } from '../patcher.js';
1
+ import chalk from '../colors.js';
2
+ import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
3
3
  import { isTuiMode } from '../runtime-mode.js';
4
4
  import { removeIndexFile } from '../project-index.js';
5
5
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
@@ -9,10 +9,27 @@ export async function deleteFile(context, args) {
9
9
  if (!filePath) {
10
10
  return { ok: false, error: 'filePath is required' };
11
11
  }
12
+ const pathKind = classifyProjectPath(rootDir, filePath);
13
+ if (pathKind === 'outside') {
14
+ return {
15
+ ok: false,
16
+ filePath,
17
+ error: `Refusing to delete outside the project root: ${filePath}. Deletable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
18
+ failureCategory: 'invalid_argument',
19
+ failureDetails: {
20
+ category: 'invalid_argument',
21
+ tool: 'delete_file',
22
+ action: 'Delete files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR).',
23
+ },
24
+ };
25
+ }
26
+ const scratchPath = pathKind === 'scratch';
12
27
  const result = deleteProjectFile(rootDir, filePath);
13
28
  if (result.deleted) {
14
- await removeIndexFile(projectIndex, filePath);
15
- invalidateShellDiagnosticsCache(rootDir, filePath);
29
+ if (!scratchPath) {
30
+ await removeIndexFile(projectIndex, filePath);
31
+ invalidateShellDiagnosticsCache(rootDir, filePath);
32
+ }
16
33
  if (!isTuiMode())
17
34
  console.log(chalk.red(` 🗑️ Deleted: ${filePath}`));
18
35
  }
@@ -21,7 +38,8 @@ export async function deleteFile(context, args) {
21
38
  filePath,
22
39
  changed: result.deleted,
23
40
  deleted: result.deleted,
41
+ ...(scratchPath ? { scratch: true } : {}),
24
42
  content: result.content,
25
- diagnostics: result.deleted ? runShellDiagnostics(rootDir) : undefined,
43
+ diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
26
44
  };
27
45
  }
@@ -1,3 +1,4 @@
1
+ import { shellJobOutput } from './shell-job-output.js';
1
2
  import { deleteFile } from './delete-file.js';
2
3
  import { findSymbol } from './find-symbol.js';
3
4
  import { getDiagnostics } from './get-diagnostics.js';
@@ -17,8 +18,10 @@ import { runNodeScript } from './run-node-script.js';
17
18
  import { restoreFilesToCheckpoint, restoreToCheckpoint, } from './restore-checkpoint.js';
18
19
  import { searchCode } from './search-code.js';
19
20
  import { getSignatureHelp } from './signature-help.js';
21
+ import { shellJobKill } from './shell-job-kill.js';
20
22
  import { strReplace } from './str-replace.js';
21
23
  import { undoEdit } from './undo-edit.js';
24
+ import { updateTodos } from './update-todos.js';
22
25
  import { writeFile } from './write-file.js';
23
26
  export const TOOL_MAP = {
24
27
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -44,6 +47,9 @@ export const TOOL_MAP = {
44
47
  undo_edit: undoEdit,
45
48
  run_command: runShellCommand,
46
49
  run_node_script: runNodeScript,
50
+ shell_job_output: shellJobOutput,
51
+ shell_job_kill: shellJobKill,
52
+ update_todos: updateTodos,
47
53
  };
48
54
  function invalidToolCall(error) {
49
55
  return {
@@ -1,15 +1,16 @@
1
- import chalk from 'chalk';
1
+ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
- import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
4
+ import { applyUnifiedPatch, classifyProjectPath, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
5
5
  import { upsertIndexFile } from '../project-index.js';
6
+ import { repairFilePath } from './path-suggest.js';
6
7
  import { isTuiMode } from '../runtime-mode.js';
7
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
8
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
10
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
10
11
  export async function patchFile(context, args) {
11
12
  const { rootDir, projectIndex, autoYes, confirmPatch } = context;
12
- const filePath = String(args.filePath ?? '').trim();
13
+ const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
13
14
  let patch = typeof args.patch === 'string' ? args.patch : '';
14
15
  if (!filePath) {
15
16
  return { ok: false, error: 'filePath is required' };
@@ -28,6 +29,21 @@ export async function patchFile(context, args) {
28
29
  failureCategory: 'invalid_argument',
29
30
  };
30
31
  }
32
+ const pathKind = classifyProjectPath(rootDir, filePath);
33
+ if (pathKind === 'outside') {
34
+ return {
35
+ ok: false,
36
+ filePath,
37
+ error: `Refusing to edit outside the project root: ${filePath}. Editable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
38
+ failureCategory: 'invalid_argument',
39
+ failureDetails: {
40
+ category: 'invalid_argument',
41
+ tool: 'patch_file',
42
+ action: 'Edit files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
43
+ },
44
+ };
45
+ }
46
+ const scratchPath = pathKind === 'scratch';
31
47
  let originalContent;
32
48
  try {
33
49
  originalContent = readProjectFile(rootDir, filePath);
@@ -67,20 +83,29 @@ export async function patchFile(context, args) {
67
83
  ok: false,
68
84
  skipped: true,
69
85
  filePath,
70
- error: 'User declined patch',
86
+ failureCategory: 'user_declined',
87
+ failureDetails: {
88
+ category: 'user_declined',
89
+ tool: 'patch_file',
90
+ action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
91
+ },
92
+ error: 'The real user rejected this proposed patch. Nothing was changed; this was not a tool failure or an automated system skip.',
71
93
  };
72
94
  }
73
95
  }
74
96
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
75
97
  let indexedChunks = 0;
76
98
  let retrievalTokensUsed = 0;
77
- if (changed) {
99
+ if (changed && !scratchPath) {
78
100
  const indexResult = await upsertIndexFile(projectIndex, filePath);
79
101
  indexedChunks = indexResult.indexedChunks;
80
102
  retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
81
103
  }
82
- invalidateShellDiagnosticsCache(rootDir, filePath);
83
- const diagnostics = runShellDiagnostics(rootDir, filePath);
104
+ let diagnostics;
105
+ if (!scratchPath) {
106
+ invalidateShellDiagnosticsCache(rootDir, filePath);
107
+ diagnostics = runShellDiagnostics(rootDir, filePath);
108
+ }
84
109
  const originalLines = originalContent.split('\n').length;
85
110
  const patchedLines = patchedContent.split('\n').length;
86
111
  if (!isTuiMode()) {
@@ -93,6 +118,7 @@ export async function patchFile(context, args) {
93
118
  filePath,
94
119
  changed,
95
120
  operation: 'patch',
121
+ ...(scratchPath ? { scratch: true } : {}),
96
122
  indexedChunks,
97
123
  retrievalTokensUsed,
98
124
  bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),