@thegitai/cli 1.0.0-preview.1 → 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 (36) hide show
  1. package/README.md +16 -4
  2. package/dist/bin/ai.js +57 -287
  3. package/dist/src/api/auth.js +2 -2
  4. package/dist/src/api/browser-login.js +72 -3
  5. package/dist/src/api/chat.js +125 -24
  6. package/dist/src/api/http.js +16 -3
  7. package/dist/src/api/models.js +9 -4
  8. package/dist/src/help-text.js +19 -7
  9. package/dist/src/patcher.js +96 -9
  10. package/dist/src/project-index.js +13 -1
  11. package/dist/src/project-orientation.js +99 -0
  12. package/dist/src/scratch-dir.js +51 -33
  13. package/dist/src/session-store.js +52 -20
  14. package/dist/src/session.js +8 -0
  15. package/dist/src/tool-executor.js +38 -6
  16. package/dist/src/tools/delete-file.js +22 -4
  17. package/dist/src/tools/patch-file.js +30 -5
  18. package/dist/src/tools/read-file.js +3 -1
  19. package/dist/src/tools/replace-document-text.js +7 -1
  20. package/dist/src/tools/run-command.js +37 -19
  21. package/dist/src/tools/run-node-script.js +24 -4
  22. package/dist/src/tools/str-replace.js +30 -5
  23. package/dist/src/tools/write-file.js +25 -5
  24. package/dist/src/turn-failure-marker.js +11 -0
  25. package/dist/src/ui/prompt-history-store.js +1 -1
  26. package/dist/src/ui/repl.js +188 -49
  27. package/dist/src/ui/tui/bridge.js +3 -0
  28. package/dist/src/ui/tui/build-frame.js +179 -82
  29. package/dist/src/ui/tui/markdown-render.js +72 -73
  30. package/dist/src/ui/tui/shell-input.js +42 -13
  31. package/dist/src/ui/tui/terminal-title.js +3 -0
  32. package/dist/src/ui/tui/terminal-writes.js +48 -0
  33. package/dist/src/ui/tui/text.js +158 -4
  34. package/dist/src/utils.js +9 -0
  35. package/package.json +18 -6
  36. package/dist/src/markdown-renderer.js +0 -112
@@ -0,0 +1,99 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { shouldIgnoreArtifactPath } from './artifact-policy.js';
4
+ const MAX_TOP_LEVEL_ENTRIES = 80;
5
+ const MAX_DECLARED_TASKS = 40;
6
+ const MAX_NAME_CHARS = 120;
7
+ const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
8
+ function normalizeName(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const normalized = value
12
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
13
+ .replace(/\s+/g, ' ')
14
+ .trim()
15
+ .slice(0, MAX_NAME_CHARS);
16
+ return normalized || null;
17
+ }
18
+ function normalizeNames(value, limit) {
19
+ if (!Array.isArray(value))
20
+ return [];
21
+ const names = [];
22
+ const seen = new Set();
23
+ for (const item of value) {
24
+ const name = normalizeName(item);
25
+ if (!name || seen.has(name))
26
+ continue;
27
+ seen.add(name);
28
+ names.push(name);
29
+ if (names.length >= limit)
30
+ break;
31
+ }
32
+ return names;
33
+ }
34
+ function readPackageTasks(rootDir) {
35
+ const packagePath = path.join(rootDir, 'package.json');
36
+ let fd = null;
37
+ try {
38
+ const pathStat = lstatSync(packagePath);
39
+ if (pathStat.isSymbolicLink() ||
40
+ !pathStat.isFile() ||
41
+ pathStat.nlink > 1 ||
42
+ pathStat.size > MAX_PACKAGE_JSON_BYTES) {
43
+ return [];
44
+ }
45
+ const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
46
+ fd = openSync(packagePath, constants.O_RDONLY | noFollow);
47
+ const fileStat = fstatSync(fd);
48
+ if (!fileStat.isFile() ||
49
+ fileStat.nlink > 1 ||
50
+ fileStat.size > MAX_PACKAGE_JSON_BYTES ||
51
+ fileStat.dev !== pathStat.dev ||
52
+ fileStat.ino !== pathStat.ino) {
53
+ return [];
54
+ }
55
+ const parsed = JSON.parse(readFileSync(fd, 'utf8'));
56
+ if (!parsed?.scripts || typeof parsed.scripts !== 'object')
57
+ return [];
58
+ return normalizeNames(Object.keys(parsed.scripts), MAX_DECLARED_TASKS);
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ finally {
64
+ if (fd !== null)
65
+ closeSync(fd);
66
+ }
67
+ }
68
+ export function normalizeProjectOrientation(value) {
69
+ if (!value || typeof value !== 'object')
70
+ return null;
71
+ const record = value;
72
+ const topLevelEntries = normalizeNames(record.topLevelEntries, MAX_TOP_LEVEL_ENTRIES);
73
+ const packageScripts = normalizeNames(record.packageScripts, MAX_DECLARED_TASKS);
74
+ if (topLevelEntries.length === 0 && packageScripts.length === 0)
75
+ return null;
76
+ return {
77
+ topLevelEntries,
78
+ packageScripts,
79
+ truncated: record.truncated === true,
80
+ };
81
+ }
82
+ export function collectProjectOrientation(rootDir) {
83
+ try {
84
+ const entries = readdirSync(rootDir, { withFileTypes: true })
85
+ .filter((entry) => !shouldIgnoreArtifactPath(entry.name))
86
+ .sort((a, b) => a.name.localeCompare(b.name));
87
+ const topLevelEntries = entries
88
+ .slice(0, MAX_TOP_LEVEL_ENTRIES)
89
+ .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
90
+ return normalizeProjectOrientation({
91
+ topLevelEntries,
92
+ packageScripts: readPackageTasks(rootDir),
93
+ truncated: entries.length > MAX_TOP_LEVEL_ENTRIES,
94
+ });
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
@@ -1,57 +1,75 @@
1
- import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
1
+ import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- let cachedScratchDir = null;
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
+ }
5
14
  export function sessionScratchDir() {
6
- if (!cachedScratchDir) {
7
- cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
- }
9
- return cachedScratchDir;
15
+ return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
10
16
  }
11
- function isSquattedScratchRoot(dir) {
17
+ function isOwnedDirectory(dir) {
12
18
  try {
13
19
  const st = lstatSync(dir);
14
20
  if (st.isSymbolicLink() || !st.isDirectory())
15
- return true;
21
+ return false;
16
22
  if (process.platform !== 'win32' &&
17
23
  typeof process.getuid === 'function' &&
18
24
  st.uid !== process.getuid()) {
19
- return true;
25
+ return false;
20
26
  }
21
- return false;
27
+ return true;
22
28
  }
23
29
  catch {
24
30
  return false;
25
31
  }
26
32
  }
27
33
  export function ensureSessionScratchDir() {
28
- const dir = sessionScratchDir();
29
- try {
30
- if (isSquattedScratchRoot(dir)) {
31
- cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
- return cachedScratchDir;
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;
33
55
  }
34
- mkdirSync(dir, { recursive: true, mode: 0o700 });
35
- if (process.platform !== 'win32') {
36
- chmodSync(dir, 0o700);
56
+ catch (error) {
57
+ return error?.code !== 'ENOENT';
37
58
  }
38
59
  }
39
- catch {
40
- }
41
- return sessionScratchDir();
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);
42
67
  }
43
68
  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)) {
69
+ const root = path.resolve(ensureSessionScratchDir());
70
+ const resolved = path.resolve(absPath);
71
+ const relative = path.relative(root, resolved);
72
+ if (!isWithinSessionScratchDir(resolved))
54
73
  return false;
55
- }
56
- return !isSquattedScratchRoot(path.join(tempRoot, first));
74
+ return !hasUnsafeScratchComponent(root, relative);
57
75
  }
@@ -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),
@@ -154,6 +162,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
154
162
  }
155
163
  }
156
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
+ }
157
180
  export function snapshotFromSession(session) {
158
181
  const now = new Date().toISOString();
159
182
  const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
@@ -168,6 +191,7 @@ export function snapshotFromSession(session) {
168
191
  createdAt,
169
192
  updatedAt: now,
170
193
  modelId: session.modelId,
194
+ branch: readGitBranch(session.rootDir),
171
195
  history: cloneJson(session.history),
172
196
  clientState: {
173
197
  editCounter: session.clientState.editCounter,
@@ -210,6 +234,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
210
234
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
211
235
  };
212
236
  }
237
+ export function sessionHasUserMessage(session) {
238
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
239
+ }
213
240
  function extractMarkedSection(text, marker) {
214
241
  const index = text.indexOf(marker);
215
242
  if (index === -1)
@@ -218,23 +245,27 @@ function extractMarkedSection(text, marker) {
218
245
  const end = after.indexOf('\n\n');
219
246
  return (end === -1 ? after : after.slice(0, end)).trim();
220
247
  }
221
- function extractLastUserMessage(history) {
222
- for (let i = history.length - 1; i >= 0; i--) {
223
- const entry = history[i];
224
- if (!entry || entry.role !== 'user')
225
- continue;
226
- const text = (entry.parts ?? [])
227
- .map((part) => (typeof part?.text === 'string' ? part.text : ''))
228
- .filter(Boolean)
229
- .join('\n')
230
- .trim();
231
- if (!text)
232
- continue;
233
- const request = extractMarkedSection(text, 'Current user request:') ||
234
- extractMarkedSection(text, 'User request:');
235
- const message = extractMarkedSection(text, 'Current user message:') ||
236
- extractMarkedSection(text, 'User message:');
237
- 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);
238
269
  }
239
270
  return '';
240
271
  }
@@ -247,8 +278,9 @@ function metadataFromSnapshot(snapshot) {
247
278
  updatedAt: snapshot.updatedAt,
248
279
  modelId: snapshot.modelId,
249
280
  messageCount: snapshot.history.length,
250
- lastUserMessage: extractLastUserMessage(snapshot.history),
281
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
251
282
  summaryPreview: '',
283
+ branch: snapshot.branch ?? null,
252
284
  };
253
285
  }
254
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);
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications, getBackgroundJob, } from './background
2
2
  import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_RECORDS, operationFromSnapshots, readFileEditSnapshot, } from './edit-journal.js';
3
3
  import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
4
4
  import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
5
+ import { classifyProjectPath } from './patcher.js';
5
6
  import { dispatchTool } from './tools/index.js';
6
7
  import { syncIndexFromDisk } from './project-index.js';
7
8
  import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
@@ -84,9 +85,38 @@ async function collectTrackedCommandMutations({ session, projectIndex, result, t
84
85
  invalidateShellDiagnosticsCache(session.rootDir);
85
86
  rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
86
87
  const priorSync = result.repoSync;
87
- if (!(priorSync &&
88
- (priorSync.added || priorSync.modified || priorSync.removed))) {
89
- result.repoSync = await syncIndexFromDisk(projectIndex);
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
+ }
90
120
  }
91
121
  result.sessionEdits = records.map((record) => ({
92
122
  id: record.id,
@@ -150,7 +180,7 @@ export async function collectBackgroundJobUiOutputMutations({ session, projectIn
150
180
  }
151
181
  }
152
182
  function recordAssistantEdit(session, call, result, before) {
153
- if (!before || !isEditToolName(call.name))
183
+ if (!before || !isEditToolName(call.name) || result?.scratch === true)
154
184
  return;
155
185
  if (!result || typeof result !== 'object' || result.ok !== true) {
156
186
  const filePath = getEditToolFilePath(call);
@@ -231,10 +261,12 @@ export async function executeLocalToolCall(toolContext, session, call) {
231
261
  !editToolWritesSeparateOutput(call)
232
262
  ? repairFilePath(session.rootDir, rawEditFilePath)
233
263
  : rawEditFilePath;
234
- if (filePathBeforeEdit) {
264
+ const tracksRepositoryEdit = filePathBeforeEdit &&
265
+ classifyProjectPath(session.rootDir, filePathBeforeEdit) === 'project';
266
+ if (tracksRepositoryEdit) {
235
267
  rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
236
268
  }
237
- const beforeEditSnapshot = filePathBeforeEdit
269
+ const beforeEditSnapshot = tracksRepositoryEdit
238
270
  ? readFileEditSnapshot(session.rootDir, filePathBeforeEdit)
239
271
  : null;
240
272
  const commandTracker = call.name === 'run_command' || call.name === 'run_node_script'
@@ -1,5 +1,5 @@
1
1
  import chalk from '../colors.js';
2
- import { deleteProjectFile } from '../patcher.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,7 +1,7 @@
1
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
6
  import { repairFilePath } from './path-suggest.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
@@ -29,6 +29,21 @@ export async function patchFile(context, args) {
29
29
  failureCategory: 'invalid_argument',
30
30
  };
31
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';
32
47
  let originalContent;
33
48
  try {
34
49
  originalContent = readProjectFile(rootDir, filePath);
@@ -68,20 +83,29 @@ export async function patchFile(context, args) {
68
83
  ok: false,
69
84
  skipped: true,
70
85
  filePath,
71
- 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.',
72
93
  };
73
94
  }
74
95
  }
75
96
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
76
97
  let indexedChunks = 0;
77
98
  let retrievalTokensUsed = 0;
78
- if (changed) {
99
+ if (changed && !scratchPath) {
79
100
  const indexResult = await upsertIndexFile(projectIndex, filePath);
80
101
  indexedChunks = indexResult.indexedChunks;
81
102
  retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
82
103
  }
83
- invalidateShellDiagnosticsCache(rootDir, filePath);
84
- const diagnostics = runShellDiagnostics(rootDir, filePath);
104
+ let diagnostics;
105
+ if (!scratchPath) {
106
+ invalidateShellDiagnosticsCache(rootDir, filePath);
107
+ diagnostics = runShellDiagnostics(rootDir, filePath);
108
+ }
85
109
  const originalLines = originalContent.split('\n').length;
86
110
  const patchedLines = patchedContent.split('\n').length;
87
111
  if (!isTuiMode()) {
@@ -94,6 +118,7 @@ export async function patchFile(context, args) {
94
118
  filePath,
95
119
  changed,
96
120
  operation: 'patch',
121
+ ...(scratchPath ? { scratch: true } : {}),
97
122
  indexedChunks,
98
123
  retrievalTokensUsed,
99
124
  bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
4
4
  import { buildSecretFilePreview, isDotenvLikePath, looksLikeEditableDotenv, shouldUseSecretFilePreview, } from '../secret-preview.js';
5
5
  import { readProjectFile } from '../patcher.js';
6
+ import { isWithinSessionScratchDir } from '../scratch-dir.js';
6
7
  import { repairFilePath } from './path-suggest.js';
7
8
  import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
8
9
  import { readFileRange, truncate } from '../utils.js';
@@ -16,6 +17,7 @@ export async function readFile(context, args) {
16
17
  return { ok: false, error: 'filePath is required' };
17
18
  }
18
19
  const projectPath = normalizeProjectRelativePath(rootDir, filePath);
20
+ const scratchPath = path.isAbsolute(filePath) && isWithinSessionScratchDir(filePath);
19
21
  if (!projectPath && !path.isAbsolute(filePath)) {
20
22
  return {
21
23
  ok: false,
@@ -37,7 +39,7 @@ export async function readFile(context, args) {
37
39
  };
38
40
  }
39
41
  let content;
40
- if (projectPath) {
42
+ if (projectPath || scratchPath) {
41
43
  try {
42
44
  content = readProjectFile(rootDir, filePath);
43
45
  }
@@ -188,7 +188,13 @@ export async function replaceDocumentText(context, args) {
188
188
  ok: false,
189
189
  skipped: true,
190
190
  filePath: targetPath,
191
- error: 'User declined replace_document_text',
191
+ failureCategory: 'user_declined',
192
+ failureDetails: {
193
+ category: 'user_declined',
194
+ tool: 'replace_document_text',
195
+ 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.',
196
+ },
197
+ error: 'The real user rejected this proposed document edit. Nothing was changed; this was not a tool failure or an automated system skip.',
192
198
  };
193
199
  }
194
200
  }