@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.31

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 (58) hide show
  1. package/README.md +39 -6
  2. package/dist/bin/ai.js +142 -383
  3. package/dist/src/agent-mode.js +1 -6
  4. package/dist/src/api/auth.js +6 -4
  5. package/dist/src/api/browser-login.js +152 -37
  6. package/dist/src/api/chat.js +258 -38
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/default-host.js +1 -0
  9. package/dist/src/api/http.js +69 -7
  10. package/dist/src/api/models.js +19 -10
  11. package/dist/src/background-jobs.js +2 -2
  12. package/dist/src/cli-args.js +19 -5
  13. package/dist/src/core/clipboard.js +7 -13
  14. package/dist/src/core/image-limits.js +56 -0
  15. package/dist/src/core/image-path-extractor.js +70 -3
  16. package/dist/src/core/session-image-store.js +199 -0
  17. package/dist/src/executor.js +25 -3
  18. package/dist/src/help-text.js +67 -18
  19. package/dist/src/permissions.js +243 -0
  20. package/dist/src/session-safety.js +0 -12
  21. package/dist/src/session-store.js +121 -20
  22. package/dist/src/session.js +14 -3
  23. package/dist/src/signin.js +58 -0
  24. package/dist/src/tool-executor.js +11 -46
  25. package/dist/src/tools/delete-file.js +15 -3
  26. package/dist/src/tools/index.js +13 -10
  27. package/dist/src/tools/patch-file.js +12 -26
  28. package/dist/src/tools/read-image-file.js +85 -0
  29. package/dist/src/tools/replace-document-text.js +28 -18
  30. package/dist/src/tools/restore-checkpoint.js +0 -1
  31. package/dist/src/tools/run-command.js +14 -71
  32. package/dist/src/tools/run-node-script.js +12 -81
  33. package/dist/src/tools/save-generated-image.js +120 -0
  34. package/dist/src/tools/str-replace.js +12 -26
  35. package/dist/src/tools/undo-edit.js +1 -6
  36. package/dist/src/tools/write-file.js +67 -11
  37. package/dist/src/turn-failure-marker.js +11 -0
  38. package/dist/src/ui/prompt-history-store.js +1 -1
  39. package/dist/src/ui/repl.js +649 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +453 -115
  42. package/dist/src/ui/tui/markdown-render.js +81 -73
  43. package/dist/src/ui/tui/shell-input.js +206 -63
  44. package/dist/src/ui/tui/terminal-theme.js +28 -0
  45. package/dist/src/ui/tui/terminal-title.js +3 -0
  46. package/dist/src/ui/tui/terminal-writes.js +48 -0
  47. package/dist/src/ui/tui/text.js +158 -4
  48. package/dist/src/ui/tui/user-input.js +568 -0
  49. package/dist/src/utils.js +9 -0
  50. package/package.json +29 -6
  51. package/dist/src/markdown-renderer.js +0 -112
  52. package/dist/src/project-index.js +0 -221
  53. package/dist/src/tools/code-intel.js +0 -472
  54. package/dist/src/tools/find-symbol.js +0 -70
  55. package/dist/src/tools/hover-symbol.js +0 -95
  56. package/dist/src/tools/list-symbols.js +0 -55
  57. package/dist/src/tools/search-code.js +0 -37
  58. package/dist/src/tools/signature-help.js +0 -118
@@ -1,14 +1,14 @@
1
1
  import chalk from '../colors.js';
2
2
  import { startBackgroundJob } from '../background-jobs.js';
3
3
  import { getBlockedCommandReason, runCommand, } from '../executor.js';
4
- import { syncIndexFromDisk } from '../project-index.js';
4
+ import { ensurePermission } from '../permissions.js';
5
5
  import { isTuiMode } from '../runtime-mode.js';
6
6
  import { redactConnectionStringCredentials } from '../secret-preview.js';
7
7
  import { buildNestedGitHint } from '../session-safety.js';
8
8
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
9
9
  const MAX_OUTPUT_CHARS = 4000;
10
10
  export async function runShellCommand(context, args) {
11
- const { rootDir, projectIndex, autoYes, confirmCommand, requestSudoPassword, onStatus, } = context;
11
+ const { rootDir, requestSudoPassword, onStatus, } = context;
12
12
  const command = String(args.command ?? '').trim();
13
13
  if (!command) {
14
14
  return { ok: false, error: 'command is required' };
@@ -31,33 +31,18 @@ export async function runShellCommand(context, args) {
31
31
  }
32
32
  if (!isTuiMode())
33
33
  console.log(chalk.bold.yellow(`\n ⚡ Command: ${command}`));
34
- if (!autoYes) {
35
- if (confirmCommand == null) {
36
- return {
37
- ok: false,
38
- command,
39
- error: 'confirmCommand is required when autoYes is false',
40
- };
41
- }
42
- const approved = await confirmCommand(runInBackground
34
+ const denied = await ensurePermission(context, {
35
+ bucket: 'run',
36
+ title: 'Approve command?',
37
+ body: runInBackground
43
38
  ? `${command}\n\nRuns as a managed background job until it exits or is killed.`
44
- : command);
45
- if (!approved) {
46
- if (!isTuiMode())
47
- console.log(chalk.dim(` ⏭ Skipped: ${command}`));
48
- return {
49
- ok: false,
50
- skipped: true,
51
- command,
52
- failureCategory: 'user_declined',
53
- failureDetails: {
54
- category: 'user_declined',
55
- tool: 'run_command',
56
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
57
- },
58
- error: 'The real user rejected this proposed command. Nothing was executed; this was not a tool failure or an automated system skip.',
59
- };
60
- }
39
+ : command,
40
+ command,
41
+ }, 'run_command', { command });
42
+ if (denied) {
43
+ if (!isTuiMode())
44
+ console.log(chalk.dim(` ⏭ Skipped: ${command}`));
45
+ return denied;
61
46
  }
62
47
  if (runInBackground) {
63
48
  return runBackgroundCommand(context, command, args.timeout_ms, repoHint);
@@ -66,12 +51,8 @@ export async function runShellCommand(context, args) {
66
51
  requestSudoPassword,
67
52
  timeout: typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined,
68
53
  });
69
- const repoSync = await syncRepoIndex(projectIndex, onStatus);
70
54
  invalidateShellDiagnosticsCache(rootDir);
71
55
  const diagnostics = buildDeferredShellDiagnostics('run_command');
72
- if (repoSync.added || repoSync.modified || repoSync.removed) {
73
- onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
74
- }
75
56
  const output = typeof result.output === 'string'
76
57
  ? boundCommandOutput(redactConnectionStringCredentials(result.output))
77
58
  : result.output;
@@ -81,8 +62,6 @@ export async function runShellCommand(context, args) {
81
62
  exitCode: result.exitCode,
82
63
  timedOut: result.timedOut,
83
64
  output,
84
- repoSync,
85
- retrievalTokensUsed: repoSync.retrievalTokensUsed,
86
65
  diagnostics,
87
66
  repoHint,
88
67
  };
@@ -96,36 +75,8 @@ export function boundCommandOutput(output) {
96
75
  `\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
97
76
  output.slice(-tailSize));
98
77
  }
99
- async function syncRepoIndex(projectIndex, onStatus) {
100
- if (!projectIndex.initialized) {
101
- return {
102
- added: 0,
103
- modified: 0,
104
- removed: 0,
105
- indexedChunks: 0,
106
- retrievalTokensUsed: 0,
107
- };
108
- }
109
- try {
110
- return await syncIndexFromDisk(projectIndex);
111
- }
112
- catch (error) {
113
- const message = error instanceof Error ? error.message : String(error);
114
- onStatus(`Command completed, but local index sync failed: ${message}`);
115
- return {
116
- added: 0,
117
- modified: 0,
118
- removed: 0,
119
- indexedChunks: 0,
120
- retrievalTokensUsed: 0,
121
- skipped: true,
122
- reason: 'local index sync failed after command execution',
123
- error: message,
124
- };
125
- }
126
- }
127
78
  async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
128
- const { rootDir, projectIndex, onStatus } = context;
79
+ const { rootDir, onStatus } = context;
129
80
  const started = await startBackgroundJob(command, rootDir, {
130
81
  startupWaitMs: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined,
131
82
  sessionId: context.sessionId,
@@ -142,11 +93,7 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
142
93
  };
143
94
  }
144
95
  const snapshot = started.snapshot;
145
- const repoSync = await syncRepoIndex(projectIndex, onStatus);
146
96
  invalidateShellDiagnosticsCache(rootDir);
147
- if (repoSync.added || repoSync.modified || repoSync.removed) {
148
- onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
149
- }
150
97
  const output = boundCommandOutput(redactConnectionStringCredentials(started.startupOutput ?? '').trim());
151
98
  if (snapshot.status === 'running') {
152
99
  return {
@@ -158,8 +105,6 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
158
105
  pid: snapshot.pid,
159
106
  output,
160
107
  note: `Background job ${snapshot.id} is running. Use shell_job_output to poll status and new output, and shell_job_kill to stop it.`,
161
- repoSync,
162
- retrievalTokensUsed: repoSync.retrievalTokensUsed,
163
108
  repoHint,
164
109
  };
165
110
  }
@@ -172,8 +117,6 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
172
117
  exitCode: snapshot.exitCode,
173
118
  output,
174
119
  note: `Background job ${snapshot.id} finished during the startup window.`,
175
- repoSync,
176
- retrievalTokensUsed: repoSync.retrievalTokensUsed,
177
120
  repoHint,
178
121
  };
179
122
  }
@@ -1,6 +1,6 @@
1
1
  import chalk from '../colors.js';
2
- import { execFileSync, spawn } from 'node:child_process';
3
- import { syncIndexFromDisk } from '../project-index.js';
2
+ import { spawn } from 'node:child_process';
3
+ import { ensurePermission } from '../permissions.js';
4
4
  import { isTuiMode } from '../runtime-mode.js';
5
5
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
6
6
  import { ensureSessionScratchDir } from '../scratch-dir.js';
@@ -56,29 +56,6 @@ function buildOutput(stdout, stderr, exitCode) {
56
56
  return trimOutput(stdout.trim());
57
57
  return trimOutput([stdout, stderr].filter(Boolean).join('\n').trim());
58
58
  }
59
- function emptyRepoSync(reason) {
60
- return {
61
- added: 0,
62
- modified: 0,
63
- removed: 0,
64
- indexedChunks: 0,
65
- retrievalTokensUsed: 0,
66
- skipped: true,
67
- ...(reason ? { reason } : {}),
68
- };
69
- }
70
- function readGitStatusSignature(rootDir) {
71
- try {
72
- return execFileSync('git', ['-C', rootDir, 'status', '--porcelain=v1', '--untracked-files=all'], {
73
- encoding: 'utf-8',
74
- stdio: ['ignore', 'pipe', 'ignore'],
75
- timeout: 10_000,
76
- });
77
- }
78
- catch {
79
- return null;
80
- }
81
- }
82
59
  function executeNodeScript(rootDir, script, timeout) {
83
60
  const effectiveTimeout = typeof timeout === 'number' && timeout > 0
84
61
  ? timeout
@@ -148,7 +125,7 @@ function executeNodeScript(rootDir, script, timeout) {
148
125
  });
149
126
  }
150
127
  export async function runNodeScript(context, args) {
151
- const { rootDir, autoYes, confirmCommand } = context;
128
+ const { rootDir } = context;
152
129
  const script = typeof args.script === 'string' ? args.script : '';
153
130
  if (!script.trim()) {
154
131
  return { ok: false, error: 'script is required' };
@@ -158,65 +135,21 @@ export async function runNodeScript(context, args) {
158
135
  console.log(chalk.bold.yellow(`\n ⚡ Node script:\n${commandForApproval}\n`));
159
136
  console.log(chalk.dim(` in: ${rootDir}\n`));
160
137
  }
161
- if (!autoYes) {
162
- if (confirmCommand == null) {
163
- return {
164
- ok: false,
165
- command: COMMAND_LABEL,
166
- error: 'confirmCommand is required when autoYes is false',
167
- };
168
- }
169
- const approved = await confirmCommand(commandForApproval);
170
- if (!approved) {
171
- if (!isTuiMode()) {
172
- console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
173
- }
174
- return {
175
- ok: false,
176
- skipped: true,
177
- command: COMMAND_LABEL,
178
- failureCategory: 'user_declined',
179
- failureDetails: {
180
- category: 'user_declined',
181
- tool: 'run_node_script',
182
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
183
- },
184
- error: 'The real user rejected this proposed script. Nothing was executed; this was not a tool failure or an automated system skip.',
185
- };
138
+ const denied = await ensurePermission(context, {
139
+ bucket: 'run',
140
+ title: 'Approve node script?',
141
+ body: commandForApproval,
142
+ }, 'run_node_script', { command: COMMAND_LABEL });
143
+ if (denied) {
144
+ if (!isTuiMode()) {
145
+ console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
186
146
  }
147
+ return denied;
187
148
  }
188
- const beforeGitStatus = readGitStatusSignature(rootDir);
189
149
  const result = await executeNodeScript(rootDir, script, typeof args.timeout_ms === 'number' && args.timeout_ms > 0
190
150
  ? args.timeout_ms
191
151
  : undefined);
192
- const afterGitStatus = readGitStatusSignature(rootDir);
193
- const gitStatusCleanBeforeAndAfter = beforeGitStatus === '' && afterGitStatus === '';
194
- const shouldSync = context.projectIndex.initialized && !gitStatusCleanBeforeAndAfter;
195
- let repoSync;
196
- if (shouldSync) {
197
- try {
198
- repoSync = await syncIndexFromDisk(context.projectIndex);
199
- }
200
- catch (error) {
201
- const message = error instanceof Error ? error.message : String(error);
202
- context.onStatus(`Node script completed, but local index sync failed: ${message}`);
203
- repoSync = {
204
- ...emptyRepoSync('local index sync failed after Node script execution'),
205
- error: message,
206
- };
207
- }
208
- }
209
- else {
210
- repoSync = emptyRepoSync(!context.projectIndex.initialized
211
- ? 'project index not initialized'
212
- : gitStatusCleanBeforeAndAfter
213
- ? 'git status clean before and after'
214
- : 'sync not needed');
215
- }
216
152
  invalidateShellDiagnosticsCache(rootDir);
217
- if (repoSync.added || repoSync.modified || repoSync.removed) {
218
- context.onStatus(`Synced repo state after Node script (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
219
- }
220
153
  return {
221
154
  ok: result.exitCode === 0,
222
155
  command: COMMAND_LABEL,
@@ -225,8 +158,6 @@ export async function runNodeScript(context, args) {
225
158
  stdout: result.stdout,
226
159
  stderr: result.stderr,
227
160
  output: result.output,
228
- repoSync,
229
- retrievalTokensUsed: repoSync.retrievalTokensUsed,
230
161
  diagnostics: buildDeferredShellDiagnostics('run_node_script'),
231
162
  };
232
163
  }
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getClientStateDir } from '../client-state.js';
4
+ import { MAX_IMAGE_SIZE_BYTES, sniffImageMimeType, } from '../core/image-limits.js';
5
+ const IMAGE_FILE_MODE = 0o600;
6
+ const IMAGE_DIR_MODE = 0o700;
7
+ export const GENERATED_IMAGES_SUBDIR = 'generated_images';
8
+ export const GENERATED_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ export function getGeneratedImagesDir(env = process.env) {
10
+ return path.join(getClientStateDir(env), GENERATED_IMAGES_SUBDIR);
11
+ }
12
+ export function sanitizeGeneratedImageBasename(raw) {
13
+ const trimmed = String(raw ?? '').trim();
14
+ const base = trimmed
15
+ .replace(/\\/g, '/')
16
+ .split('/')
17
+ .pop()
18
+ ?.replace(/^\.+/, '')
19
+ .replace(/[^\w.\-]+/g, '-')
20
+ .replace(/-+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .replace(/-\.|\.-/g, '.')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, 80);
25
+ if (!base || base === '.' || base === '..') {
26
+ return `generated-${Date.now()}.png`;
27
+ }
28
+ if (/\.png$/i.test(base)) {
29
+ return base.replace(/\.png$/i, '.png');
30
+ }
31
+ const withoutExt = base.replace(/\.[^.]+$/, '').replace(/-+$/g, '');
32
+ return `${withoutExt || 'generated'}.png`;
33
+ }
34
+ function uniquePath(dir, basename) {
35
+ const candidate = path.join(dir, basename);
36
+ if (!existsSync(candidate)) {
37
+ return candidate;
38
+ }
39
+ const ext = path.extname(basename) || '.png';
40
+ const stem = path.basename(basename, ext);
41
+ for (let i = 2; i < 10_000; i += 1) {
42
+ const next = path.join(dir, `${stem}-${i}${ext}`);
43
+ if (!existsSync(next)) {
44
+ return next;
45
+ }
46
+ }
47
+ return path.join(dir, `${stem}-${Date.now()}${ext}`);
48
+ }
49
+ export function sweepGeneratedImages({ maxAgeMs = GENERATED_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), } = {}) {
50
+ const dir = getGeneratedImagesDir(env);
51
+ if (!existsSync(dir))
52
+ return 0;
53
+ let removed = 0;
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return 0;
60
+ }
61
+ for (const entry of entries) {
62
+ const filePath = path.join(dir, entry);
63
+ try {
64
+ if (now - statSync(filePath).mtimeMs < maxAgeMs)
65
+ continue;
66
+ rmSync(filePath, { force: true });
67
+ removed += 1;
68
+ }
69
+ catch {
70
+ }
71
+ }
72
+ return removed;
73
+ }
74
+ export function saveGeneratedImage(args) {
75
+ const base64Data = String(args.base64Data ?? '').trim();
76
+ if (!base64Data) {
77
+ return {
78
+ ok: false,
79
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
80
+ failureCategory: 'tool_exception',
81
+ };
82
+ }
83
+ let bytes;
84
+ try {
85
+ bytes = Buffer.from(base64Data, 'base64');
86
+ }
87
+ catch {
88
+ return {
89
+ ok: false,
90
+ error: 'Generated image bytes are invalid.',
91
+ failureCategory: 'invalid_argument',
92
+ };
93
+ }
94
+ if (bytes.length === 0 || bytes.length > MAX_IMAGE_SIZE_BYTES) {
95
+ return {
96
+ ok: false,
97
+ error: 'Generated image exceeded the allowed size limit.',
98
+ failureCategory: 'invalid_argument',
99
+ };
100
+ }
101
+ const sniffed = sniffImageMimeType(bytes);
102
+ if (sniffed !== 'image/png') {
103
+ return {
104
+ ok: false,
105
+ error: 'Generated image must be a PNG.',
106
+ failureCategory: 'invalid_argument',
107
+ };
108
+ }
109
+ const env = args.env ?? process.env;
110
+ const dir = getGeneratedImagesDir(env);
111
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
112
+ const basename = sanitizeGeneratedImageBasename(args.suggestedFilename ?? args.filename);
113
+ const target = uniquePath(dir, basename);
114
+ writeFileSync(target, bytes, { mode: IMAGE_FILE_MODE });
115
+ return {
116
+ ok: true,
117
+ path: target,
118
+ message: `Image saved to ${target}`,
119
+ };
120
+ }
@@ -3,10 +3,10 @@ import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { classifyProjectPath, readProjectFile, writeProjectFile, } from '../patcher.js';
5
5
  import { repairFilePath } from './path-suggest.js';
6
- import { upsertIndexFile } from '../project-index.js';
7
6
  import { isTuiMode } from '../runtime-mode.js';
8
7
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
+ import { ensurePermission } from '../permissions.js';
10
10
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
11
  function countOccurrences(haystack, needle) {
12
12
  if (needle.length === 0)
@@ -76,7 +76,7 @@ function buildStrReplacePreview(oldString, newString) {
76
76
  return `@@ str_replace @@\n${minus}\n${plus}`;
77
77
  }
78
78
  export async function strReplace(context, args) {
79
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
79
+ const { rootDir } = context;
80
80
  const filePath = repairFilePath(rootDir, String(args.filePath ?? args.file_path ?? '').trim());
81
81
  let oldString = typeof args.old_string === 'string'
82
82
  ? args.old_string
@@ -175,35 +175,23 @@ export async function strReplace(context, args) {
175
175
  }
176
176
  console.log();
177
177
  }
178
- if (!autoYes && confirmPatch) {
179
- const confirmed = await confirmPatch(filePath, preview);
180
- if (!confirmed) {
178
+ if (!scratchPath) {
179
+ const denied = await ensurePermission(context, {
180
+ bucket: 'edit',
181
+ title: 'Approve patch?',
182
+ body: 'Review changes before applying.',
183
+ filePath,
184
+ diff: preview,
185
+ }, 'str_replace', { filePath });
186
+ if (denied) {
181
187
  if (!isTuiMode())
182
188
  console.log(chalk.dim(` ⏭ str_replace skipped: ${filePath}`));
183
- return {
184
- ok: false,
185
- skipped: true,
186
- filePath,
187
- failureCategory: 'user_declined',
188
- failureDetails: {
189
- category: 'user_declined',
190
- tool: 'str_replace',
191
- 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.',
192
- },
193
- error: 'The real user rejected this proposed edit. Nothing was changed; this was not a tool failure or an automated system skip.',
194
- };
189
+ return denied;
195
190
  }
196
191
  }
197
192
  const nextContent = originalContent.split(oldString).join(newString);
198
193
  const { changed } = writeProjectFile(rootDir, filePath, nextContent);
199
194
  const replacements = changed ? (replaceAll ? n : 1) : 0;
200
- let indexedChunks = 0;
201
- let retrievalTokensUsed = 0;
202
- if (changed && !scratchPath) {
203
- const indexResult = await upsertIndexFile(projectIndex, filePath);
204
- indexedChunks = indexResult.indexedChunks;
205
- retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
206
- }
207
195
  let diagnostics;
208
196
  if (!scratchPath) {
209
197
  invalidateShellDiagnosticsCache(rootDir, filePath);
@@ -223,8 +211,6 @@ export async function strReplace(context, args) {
223
211
  operation: 'str_replace',
224
212
  ...(scratchPath ? { scratch: true } : {}),
225
213
  replacements,
226
- indexedChunks,
227
- retrievalTokensUsed,
228
214
  bytesWritten: Buffer.byteLength(nextContent, 'utf-8'),
229
215
  diagnostics,
230
216
  message: changed ? undefined : 'The provided replacement resulted in no changes to the file content.',
@@ -1,7 +1,6 @@
1
1
  import chalk from '../colors.js';
2
2
  import { hashStoredContent, readFileEditSnapshot, storedContentBuffer, } from '../edit-journal.js';
3
3
  import { deleteProjectFile, writeProjectFile, writeProjectFileBuffer, } from '../patcher.js';
4
- import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
5
4
  import { isTuiMode } from '../runtime-mode.js';
6
5
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
7
6
  function normalizeTarget(value) {
@@ -101,10 +100,9 @@ function validateUndoPlan(rootDir, records) {
101
100
  return { ok: true };
102
101
  }
103
102
  async function applyUndo(context, record) {
104
- const { rootDir, projectIndex } = context;
103
+ const { rootDir } = context;
105
104
  if (record.operation === 'create') {
106
105
  deleteProjectFile(rootDir, record.filePath);
107
- await removeIndexFile(projectIndex, record.filePath);
108
106
  return { changed: true };
109
107
  }
110
108
  if (record.beforeContent === null) {
@@ -113,9 +111,6 @@ async function applyUndo(context, record) {
113
111
  const { changed } = record.beforeContentEncoding === 'base64'
114
112
  ? writeProjectFileBuffer(rootDir, record.filePath, storedContentBuffer(record.beforeContent, record.beforeContentEncoding))
115
113
  : writeProjectFile(rootDir, record.filePath, record.beforeContent);
116
- if (changed) {
117
- await upsertIndexFile(projectIndex, record.filePath);
118
- }
119
114
  return { changed };
120
115
  }
121
116
  function summarizeUndoRecord(record) {
@@ -2,13 +2,48 @@ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { classifyProjectPath, writeProjectFile } from '../patcher.js';
5
- import { upsertIndexFile } from '../project-index.js';
5
+ import { readFileEditSnapshot } from '../edit-journal.js';
6
6
  import { isTuiMode } from '../runtime-mode.js';
7
7
  import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
8
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
+ import { ensurePermission } from '../permissions.js';
9
10
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
+ const MAX_WRITE_PREVIEW_LINES = 400;
12
+ function boundedLines(text, limit) {
13
+ const lines = [];
14
+ let omitted = 0;
15
+ let start = 0;
16
+ for (;;) {
17
+ const newline = text.indexOf('\n', start);
18
+ if (lines.length < limit) {
19
+ lines.push(text.slice(start, newline === -1 ? undefined : newline));
20
+ }
21
+ else {
22
+ omitted += 1;
23
+ }
24
+ if (newline === -1)
25
+ break;
26
+ start = newline + 1;
27
+ }
28
+ return { lines, omitted };
29
+ }
30
+ function buildWritePreview(previous, next) {
31
+ const rows = ['@@ write_file @@'];
32
+ const append = (text, sign, marker) => {
33
+ const { lines, omitted } = boundedLines(text, MAX_WRITE_PREVIEW_LINES);
34
+ for (const line of lines)
35
+ rows.push(`${sign}${line}`);
36
+ if (omitted > 0) {
37
+ rows.push(`@@ ${omitted} more ${marker} line(s) not shown @@`);
38
+ }
39
+ };
40
+ if (previous !== null)
41
+ append(previous, '-', 'removed');
42
+ append(next, '+', 'added');
43
+ return rows.join('\n');
44
+ }
10
45
  export async function writeFile(context, args) {
11
- const { rootDir, projectIndex } = context;
46
+ const { rootDir } = context;
12
47
  const filePath = String(args.filePath ?? '').trim();
13
48
  let content = typeof args.content === 'string' ? args.content : '';
14
49
  if (!filePath) {
@@ -60,14 +95,37 @@ export async function writeFile(context, args) {
60
95
  };
61
96
  }
62
97
  content = resolveRedactionTokens(context.safety, content, coveragePath, currentHash);
63
- const { changed } = writeProjectFile(rootDir, filePath, content);
64
- let indexedChunks = 0;
65
- let retrievalTokensUsed = 0;
66
- if (changed && !scratchPath) {
67
- const indexResult = await upsertIndexFile(projectIndex, filePath);
68
- indexedChunks = indexResult.indexedChunks;
69
- retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
98
+ if (!scratchPath) {
99
+ const before = currentHash === null ? null : readFileEditSnapshot(rootDir, filePath);
100
+ const existing = before && before.contentEncoding === 'utf8' ? before.content : null;
101
+ const denied = await ensurePermission(context, {
102
+ bucket: currentHash === null ? 'create' : 'edit',
103
+ title: currentHash === null ? 'Approve new file?' : 'Approve patch?',
104
+ body: 'Review changes before applying.',
105
+ filePath,
106
+ diff: buildWritePreview(existing, content),
107
+ }, 'write_file', { filePath });
108
+ if (denied) {
109
+ if (!isTuiMode())
110
+ console.log(chalk.dim(` ⏭ write_file skipped: ${filePath}`));
111
+ return denied;
112
+ }
113
+ if (getCurrentFileHash(rootDir, filePath) !== currentHash) {
114
+ return {
115
+ ok: false,
116
+ filePath,
117
+ failureCategory: 'conflict',
118
+ error: `write_file refused: ${filePath} changed on disk while the approval prompt was open.`,
119
+ failureDetails: {
120
+ category: 'conflict',
121
+ tool: 'write_file',
122
+ action: 'Re-read the file to see its current contents, then decide whether the write is still correct and retry.',
123
+ },
124
+ currentHash,
125
+ };
126
+ }
70
127
  }
128
+ const { changed } = writeProjectFile(rootDir, filePath, content);
71
129
  let diagnostics;
72
130
  if (!scratchPath) {
73
131
  invalidateShellDiagnosticsCache(rootDir, filePath);
@@ -84,8 +142,6 @@ export async function writeFile(context, args) {
84
142
  changed,
85
143
  operation: 'write',
86
144
  ...(scratchPath ? { scratch: true } : {}),
87
- indexedChunks,
88
- retrievalTokensUsed,
89
145
  bytesWritten: Buffer.byteLength(content, 'utf-8'),
90
146
  diagnostics,
91
147
  };
@@ -0,0 +1,11 @@
1
+ const TURN_FAILURE_MARKER_PATTERN = /^Turn failed before completion: ([a-z][a-z0-9_-]*)\.?$/i;
2
+ export function formatTurnFailureMarker(category) {
3
+ const normalized = category.trim().toLowerCase();
4
+ const safeCategory = /^[a-z][a-z0-9_-]*$/.test(normalized)
5
+ ? normalized
6
+ : 'unknown_error';
7
+ return `Turn failed before completion: ${safeCategory}.`;
8
+ }
9
+ export function isTurnFailureMarker(text) {
10
+ return TURN_FAILURE_MARKER_PATTERN.test(text.trim());
11
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
4
  const HISTORY_FILE = 'prompt-history.json';
5
5
  const LEGACY_HISTORY_FILE = 'prompt-history.txt';
6
- export const MAX_PROMPT_HISTORY_ENTRIES = 15;
6
+ export const MAX_PROMPT_HISTORY_ENTRIES = 20;
7
7
  const ENTRY_SEPARATOR = '\x1e';
8
8
  function getHistoryFilePath(env = process.env) {
9
9
  return path.join(getClientStateDir(env), HISTORY_FILE);