@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,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { normalizeAgentMode, } from './agent-mode.js';
3
+ import { createSessionGrants, } from './permissions.js';
3
4
  import { createSessionSafetyState, } from './session-safety.js';
4
5
  import { clampInteger } from './utils.js';
5
6
  const DEFAULT_MAX_TOOL_STEPS = 32;
@@ -31,7 +32,7 @@ function preserveProviderSelection(serverState) {
31
32
  : null;
32
33
  return providerSelection ? { providerSelection } : {};
33
34
  }
34
- export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, confirmCommand = null, confirmPatch = null, requestSudoPassword = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
+ export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, requestPermission = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
36
  const createdAt = new Date().toISOString();
36
37
  const initialAgentMode = normalizeAgentMode(agentMode ?? (autoYes ? 'auto-accept' : 'default'));
37
38
  return {
@@ -43,9 +44,10 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
43
44
  onStatus: onStatus ?? defaultStatus,
44
45
  onContextLog: onContextLog ?? defaultContextLog,
45
46
  onToolEvent,
46
- confirmCommand,
47
- confirmPatch,
47
+ grants: createSessionGrants(),
48
+ requestPermission,
48
49
  requestSudoPassword,
50
+ requestUserInput,
49
51
  history: JSON.parse(JSON.stringify(history)),
50
52
  initialized: true,
51
53
  sessionId,
@@ -69,8 +71,17 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
69
71
  serverState: cloneOpaqueState(serverState),
70
72
  };
71
73
  }
74
+ export function startNewConversation(session) {
75
+ clearConversation(session);
76
+ const createdAt = new Date().toISOString();
77
+ session.sessionId = createSessionId();
78
+ session.sessionName = null;
79
+ session.sessionCreatedAt = createdAt;
80
+ session.sessionUpdatedAt = createdAt;
81
+ }
72
82
  export function clearConversation(session) {
73
83
  session.history = [];
84
+ session.grants = createSessionGrants();
74
85
  session.serverState = preserveProviderSelection(session.serverState);
75
86
  session.turnState = {
76
87
  id: null,
@@ -0,0 +1,58 @@
1
+ import { stdin as input, stdout as output } from 'node:process';
2
+ import readline from 'node:readline/promises';
3
+ import chalk from './colors.js';
4
+ import { auth } from './api/index.js';
5
+ import { DEFAULT_THEGITAI_HOST } from './api/default-host.js';
6
+ import { loginViaBrowser } from './api/browser-login.js';
7
+ function hyperlink(url, label) {
8
+ if (process.stdout.isTTY !== true)
9
+ return label;
10
+ return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
11
+ }
12
+ export function formatSignInScreen({ url }) {
13
+ return [
14
+ '',
15
+ ` ${chalk.bold.cyan('TheGitAI')}`,
16
+ '',
17
+ ` ${chalk.bold('Sign in to continue.')}`,
18
+ ' Your browser should open automatically. If not, copy this URL:',
19
+ '',
20
+ ` ${chalk.cyan(url)}`,
21
+ '',
22
+ ` ${hyperlink(url, chalk.cyan('→ Click here to authenticate'))}`,
23
+ '',
24
+ ' If this terminal is not on the computer where that page opened, choose',
25
+ ` ${chalk.bold('"On another computer"')} there and paste the code it gives you below.`,
26
+ '',
27
+ ].join('\n');
28
+ }
29
+ async function promptForCode(signal) {
30
+ const rl = readline.createInterface({ input, output });
31
+ try {
32
+ return await rl.question(` ${chalk.bold('Authorization code:')} `, { signal });
33
+ }
34
+ finally {
35
+ rl.close();
36
+ }
37
+ }
38
+ export async function runSignIn({ env = process.env, login = loginViaBrowser, write = auth.writeCliAuthConfig, log = (line) => console.log(line), } = {}) {
39
+ const result = await login({
40
+ serverUrl: DEFAULT_THEGITAI_HOST,
41
+ deviceName: env.THEGITAI_DEVICE_NAME?.trim() || undefined,
42
+ onUrl: (url) => log(formatSignInScreen({ url })),
43
+ onBrowserOpen: (opened) => {
44
+ if (!opened) {
45
+ log(chalk.dim(' (no browser opened — use the URL or the code box above)'));
46
+ }
47
+ },
48
+ promptCode: promptForCode,
49
+ onPasteRejected: (message) => log(` ${chalk.red('✖')} ${message}\n` +
50
+ ' Codes last 10 minutes. If that page has been open a while, run `ai`\n' +
51
+ ' again for a fresh one.'),
52
+ });
53
+ write(result, env);
54
+ log('');
55
+ log(chalk.green(` ✓ Signed in as ${result.customer.email}`));
56
+ log('');
57
+ return result;
58
+ }
@@ -4,7 +4,6 @@ import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ens
4
4
  import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
5
5
  import { classifyProjectPath } from './patcher.js';
6
6
  import { dispatchTool } from './tools/index.js';
7
- import { syncIndexFromDisk } from './project-index.js';
8
7
  import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
9
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
10
9
  import { extractTodosArg } from './todo-list.js';
@@ -69,7 +68,7 @@ function editToolWritesSeparateOutput(call) {
69
68
  const output = args.outputPath ?? args.output_path;
70
69
  return typeof output === 'string' && output.trim().length > 0;
71
70
  }
72
- async function collectTrackedCommandMutations({ session, projectIndex, result, tracker, toolName, toolCallId, turnId, }) {
71
+ async function collectTrackedCommandMutations({ session, result, tracker, toolName, toolCallId, turnId, }) {
73
72
  const checkpoint = ensureActiveCheckpoint(session.clientState.safety, turnId);
74
73
  const records = collectCommandMutations({
75
74
  state: session.clientState.safety,
@@ -84,40 +83,11 @@ async function collectTrackedCommandMutations({ session, projectIndex, result, t
84
83
  return;
85
84
  invalidateShellDiagnosticsCache(session.rootDir);
86
85
  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
- }
86
+ result.repoSync = {
87
+ added: records.filter((record) => record.operation === 'create').length,
88
+ modified: records.filter((record) => record.operation === 'update').length,
89
+ removed: records.filter((record) => record.operation === 'delete').length,
90
+ };
121
91
  result.sessionEdits = records.map((record) => ({
122
92
  id: record.id,
123
93
  filePath: record.filePath,
@@ -127,7 +97,7 @@ async function collectTrackedCommandMutations({ session, projectIndex, result, t
127
97
  }));
128
98
  result.diagnostics = runShellDiagnostics(session.rootDir);
129
99
  }
130
- export async function collectBackgroundJobUiKillMutations({ session, projectIndex, jobId, result, }) {
100
+ export async function collectBackgroundJobUiKillMutations({ session, jobId, result, }) {
131
101
  const normalizedJobId = String(jobId ?? result.snapshot?.id ?? '').trim();
132
102
  if (!normalizedJobId || !result.ok)
133
103
  return;
@@ -137,7 +107,6 @@ export async function collectBackgroundJobUiKillMutations({ session, projectInde
137
107
  const mutationResult = result;
138
108
  await collectTrackedCommandMutations({
139
109
  session,
140
- projectIndex,
141
110
  result: mutationResult,
142
111
  tracker: tracked.tracker,
143
112
  toolName: tracked.toolName,
@@ -151,7 +120,7 @@ export async function collectBackgroundJobUiKillMutations({ session, projectInde
151
120
  backgroundCommandTrackers.delete(normalizedJobId);
152
121
  }
153
122
  }
154
- export async function collectBackgroundJobUiOutputMutations({ session, projectIndex, jobId, }) {
123
+ export async function collectBackgroundJobUiOutputMutations({ session, jobId, }) {
155
124
  const normalizedJobId = String(jobId ?? '').trim();
156
125
  if (!normalizedJobId)
157
126
  return;
@@ -165,7 +134,6 @@ export async function collectBackgroundJobUiOutputMutations({ session, projectIn
165
134
  return;
166
135
  await collectTrackedCommandMutations({
167
136
  session,
168
- projectIndex,
169
137
  result: {},
170
138
  tracker: tracked.tracker,
171
139
  toolName: tracked.toolName,
@@ -244,7 +212,7 @@ function recordAssistantEdit(session, call, result, before) {
244
212
  });
245
213
  clearEditFailure(session.clientState.safety, filePath);
246
214
  }
247
- export async function executeLocalToolCall(toolContext, session, call) {
215
+ export async function executeLocalToolCall(session, call) {
248
216
  session.onStatus(formatToolCallForStatus(call));
249
217
  try {
250
218
  const agentModeBlocked = buildAgentModeToolBlockedResult(session.agentMode, call);
@@ -275,10 +243,9 @@ export async function executeLocalToolCall(toolContext, session, call) {
275
243
  const context = {
276
244
  rootDir: session.rootDir,
277
245
  sessionId: session.sessionId,
278
- projectIndex: toolContext.projectIndex,
279
246
  autoYes: session.autoYes,
280
- confirmCommand: session.confirmCommand,
281
- confirmPatch: session.confirmPatch,
247
+ grants: session.grants,
248
+ requestPermission: session.requestPermission,
282
249
  requestSudoPassword: session.requestSudoPassword,
283
250
  onStatus: session.onStatus,
284
251
  editJournal: session.clientState.editJournal,
@@ -299,7 +266,6 @@ export async function executeLocalToolCall(toolContext, session, call) {
299
266
  if (result && typeof result === 'object' && commandTracker) {
300
267
  await collectTrackedCommandMutations({
301
268
  session,
302
- projectIndex: toolContext.projectIndex,
303
269
  result,
304
270
  tracker: commandTracker,
305
271
  toolName: call.name,
@@ -326,7 +292,6 @@ export async function executeLocalToolCall(toolContext, session, call) {
326
292
  if (tracked) {
327
293
  await collectTrackedCommandMutations({
328
294
  session,
329
- projectIndex: toolContext.projectIndex,
330
295
  result,
331
296
  tracker: tracked.tracker,
332
297
  toolName: tracked.toolName,
@@ -1,10 +1,10 @@
1
1
  import chalk from '../colors.js';
2
2
  import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
3
3
  import { isTuiMode } from '../runtime-mode.js';
4
- import { removeIndexFile } from '../project-index.js';
5
4
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
5
+ import { ensurePermission } from '../permissions.js';
6
6
  export async function deleteFile(context, args) {
7
- const { rootDir, projectIndex } = context;
7
+ const { rootDir } = context;
8
8
  const filePath = String(args.filePath ?? '').trim();
9
9
  if (!filePath) {
10
10
  return { ok: false, error: 'filePath is required' };
@@ -24,10 +24,22 @@ export async function deleteFile(context, args) {
24
24
  };
25
25
  }
26
26
  const scratchPath = pathKind === 'scratch';
27
+ if (!scratchPath) {
28
+ const denied = await ensurePermission(context, {
29
+ bucket: 'delete',
30
+ title: 'Approve file deletion?',
31
+ body: `Delete ${filePath}`,
32
+ filePath,
33
+ }, 'delete_file', { filePath });
34
+ if (denied) {
35
+ if (!isTuiMode())
36
+ console.log(chalk.dim(` ⏭ Delete skipped: ${filePath}`));
37
+ return denied;
38
+ }
39
+ }
27
40
  const result = deleteProjectFile(rootDir, filePath);
28
41
  if (result.deleted) {
29
42
  if (!scratchPath) {
30
- await removeIndexFile(projectIndex, filePath);
31
43
  invalidateShellDiagnosticsCache(rootDir, filePath);
32
44
  }
33
45
  if (!isTuiMode())
@@ -1,14 +1,11 @@
1
1
  import { shellJobOutput } from './shell-job-output.js';
2
2
  import { deleteFile } from './delete-file.js';
3
- import { findSymbol } from './find-symbol.js';
4
3
  import { getDiagnostics } from './get-diagnostics.js';
5
4
  import { grepCode } from './grep-code.js';
6
- import { hoverSymbol } from './hover-symbol.js';
7
5
  import { listDirectories } from './list-directories.js';
8
6
  import { listCheckpoints } from './list-checkpoints.js';
9
7
  import { listFiles } from './list-files.js';
10
8
  import { listSessionEdits } from './list-session-edits.js';
11
- import { listSymbols } from './list-symbols.js';
12
9
  import { patchFile } from './patch-file.js';
13
10
  import { readDocument } from './read-document.js';
14
11
  import { readFile } from './read-file.js';
@@ -16,25 +13,20 @@ import { replaceDocumentText } from './replace-document-text.js';
16
13
  import { runShellCommand } from './run-command.js';
17
14
  import { runNodeScript } from './run-node-script.js';
18
15
  import { restoreFilesToCheckpoint, restoreToCheckpoint, } from './restore-checkpoint.js';
19
- import { searchCode } from './search-code.js';
20
- import { getSignatureHelp } from './signature-help.js';
21
16
  import { shellJobKill } from './shell-job-kill.js';
22
17
  import { strReplace } from './str-replace.js';
23
18
  import { undoEdit } from './undo-edit.js';
24
19
  import { updateTodos } from './update-todos.js';
20
+ import { readImageFile } from './read-image-file.js';
21
+ import { saveGeneratedImage } from './save-generated-image.js';
25
22
  import { writeFile } from './write-file.js';
26
23
  export const TOOL_MAP = {
27
- search_code: (context, args) => searchCode(context.projectIndex, args),
28
24
  list_files: (context, args) => listFiles(context, args),
29
25
  list_directories: (context, args) => listDirectories(context, args),
30
26
  read_file: (context, args) => readFile(context, args),
31
27
  read_document: (context, args) => readDocument(context.rootDir, args, context.env),
32
28
  replace_document_text: replaceDocumentText,
33
29
  grep_code: (context, args) => grepCode(context.rootDir, args),
34
- find_symbol: (context, args) => findSymbol(context, args),
35
- list_symbols: (context, args) => listSymbols(context, args),
36
- hover_symbol: (context, args) => hoverSymbol(context, args),
37
- signature_help: (context, args) => getSignatureHelp(context, args),
38
30
  get_diagnostics: (context, args) => getDiagnostics(context, args),
39
31
  list_checkpoints: (context) => listCheckpoints(context),
40
32
  list_session_edits: (context) => listSessionEdits(context),
@@ -50,6 +42,17 @@ export const TOOL_MAP = {
50
42
  shell_job_output: shellJobOutput,
51
43
  shell_job_kill: shellJobKill,
52
44
  update_todos: updateTodos,
45
+ analyze_image: (context, args) => readImageFile(context, args),
46
+ generate_image: (_context, args) => {
47
+ if (!String(args.base64Data ?? '').trim()) {
48
+ return {
49
+ ok: false,
50
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
51
+ failureCategory: 'tool_exception',
52
+ };
53
+ }
54
+ return saveGeneratedImage(args);
55
+ },
53
56
  };
54
57
  function invalidToolCall(error) {
55
58
  return {
@@ -2,14 +2,14 @@ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { applyUnifiedPatch, classifyProjectPath, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
5
- import { upsertIndexFile } from '../project-index.js';
6
5
  import { repairFilePath } from './path-suggest.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
  export async function patchFile(context, args) {
12
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
12
+ const { rootDir } = context;
13
13
  const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
14
14
  let patch = typeof args.patch === 'string' ? args.patch : '';
15
15
  if (!filePath) {
@@ -74,33 +74,21 @@ export async function patchFile(context, args) {
74
74
  };
75
75
  }
76
76
  renderDiffPreview(filePath, patch);
77
- if (!autoYes && confirmPatch) {
78
- const confirmed = await confirmPatch(filePath, patch);
79
- if (!confirmed) {
77
+ if (!scratchPath) {
78
+ const denied = await ensurePermission(context, {
79
+ bucket: getCurrentFileHash(rootDir, filePath) === null ? 'create' : 'edit',
80
+ title: 'Approve patch?',
81
+ body: 'Review changes before applying.',
82
+ filePath,
83
+ diff: patch,
84
+ }, 'patch_file', { filePath });
85
+ if (denied) {
80
86
  if (!isTuiMode())
81
87
  console.log(chalk.dim(` ⏭ Patch skipped: ${filePath}`));
82
- return {
83
- ok: false,
84
- skipped: true,
85
- filePath,
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.',
93
- };
88
+ return denied;
94
89
  }
95
90
  }
96
91
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
97
- let indexedChunks = 0;
98
- let retrievalTokensUsed = 0;
99
- if (changed && !scratchPath) {
100
- const indexResult = await upsertIndexFile(projectIndex, filePath);
101
- indexedChunks = indexResult.indexedChunks;
102
- retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
103
- }
104
92
  let diagnostics;
105
93
  if (!scratchPath) {
106
94
  invalidateShellDiagnosticsCache(rootDir, filePath);
@@ -119,8 +107,6 @@ export async function patchFile(context, args) {
119
107
  changed,
120
108
  operation: 'patch',
121
109
  ...(scratchPath ? { scratch: true } : {}),
122
- indexedChunks,
123
- retrievalTokensUsed,
124
110
  bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),
125
111
  diagnostics,
126
112
  };
@@ -0,0 +1,85 @@
1
+ import path from 'node:path';
2
+ import { normalizeProjectRelativePath } from '../artifact-policy.js';
3
+ import { SessionImageError, getImageStoreSession, isSessionStorePath, readSessionImage, readSessionImageByIndex, storeSessionImageFromPath, } from '../core/session-image-store.js';
4
+ export async function readImageFile(context, args) {
5
+ const rawPath = String(args.path ?? args.filePath ?? args.file_path ?? '').trim();
6
+ if (!rawPath) {
7
+ const requested = Number(args.imageIndex ?? args.image_index ?? args.index);
8
+ const stored = Number.isInteger(requested)
9
+ ? readSessionImageByIndex(requested)
10
+ : null;
11
+ if (!stored) {
12
+ return {
13
+ ok: false,
14
+ error: Number.isInteger(requested)
15
+ ? `Image #${requested} is not in this session's image store.`
16
+ : 'path is required and must name an image file on this machine.',
17
+ failureCategory: Number.isInteger(requested)
18
+ ? 'not_found'
19
+ : 'missing_required_argument',
20
+ };
21
+ }
22
+ return {
23
+ ok: true,
24
+ imageBytes: {
25
+ base64Data: stored.base64Data,
26
+ mimeType: stored.mimeType,
27
+ cachePath: stored.cachePath,
28
+ index: stored.index,
29
+ },
30
+ };
31
+ }
32
+ let resolved;
33
+ if (path.isAbsolute(rawPath)) {
34
+ resolved = rawPath;
35
+ }
36
+ else if (normalizeProjectRelativePath(context.rootDir, rawPath)) {
37
+ resolved = path.resolve(context.rootDir, rawPath);
38
+ }
39
+ else {
40
+ return {
41
+ ok: false,
42
+ error: `Refusing to access path outside the project root: ${rawPath}`,
43
+ failureCategory: 'permission_denied',
44
+ };
45
+ }
46
+ try {
47
+ const stored = isSessionStorePath(resolved)
48
+ ? readSessionImage(resolved)
49
+ : storeSessionImageFromPath({
50
+ sessionId: getImageStoreSession() ?? 'unbound',
51
+ sourcePath: resolved,
52
+ });
53
+ if (!stored) {
54
+ return {
55
+ ok: false,
56
+ error: `Not a readable image: ${resolved}`,
57
+ failureCategory: 'not_found',
58
+ };
59
+ }
60
+ return {
61
+ ok: true,
62
+ imageBytes: {
63
+ base64Data: stored.base64Data,
64
+ mimeType: stored.mimeType,
65
+ filePath: resolved,
66
+ cachePath: stored.cachePath,
67
+ index: stored.index,
68
+ },
69
+ };
70
+ }
71
+ catch (err) {
72
+ if (err instanceof SessionImageError) {
73
+ return {
74
+ ok: false,
75
+ error: err.message,
76
+ failureCategory: err.code === 'NOT_FOUND' ? 'not_found' : 'invalid_argument',
77
+ };
78
+ }
79
+ return {
80
+ ok: false,
81
+ error: `Could not read image: ${err?.message ?? err}`,
82
+ failureCategory: 'tool_exception',
83
+ };
84
+ }
85
+ }
@@ -6,6 +6,8 @@ import { readCliAuthConfig } from '../api/auth.js';
6
6
  import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
7
7
  import { repairFilePath, suggestClosestPath } from './path-suggest.js';
8
8
  import { isTuiMode } from '../runtime-mode.js';
9
+ import { ensurePermission } from '../permissions.js';
10
+ import { getCurrentFileHash } from '../session-safety.js';
9
11
  function normalizeReplacements(value) {
10
12
  if (!Array.isArray(value))
11
13
  return [];
@@ -178,27 +180,35 @@ export async function replaceDocumentText(context, args) {
178
180
  }
179
181
  const preview = String(serverResult.preview ?? '');
180
182
  renderPreview(targetPath, preview);
181
- if (!context.autoYes && context.confirmPatch) {
182
- const confirmed = await context.confirmPatch(targetPath, preview);
183
- if (!confirmed) {
184
- if (!isTuiMode()) {
185
- console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
186
- }
187
- return {
188
- ok: false,
189
- skipped: true,
190
- filePath: targetPath,
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.',
198
- };
183
+ const targetHashBeforeApproval = getCurrentFileHash(context.rootDir, targetPath);
184
+ const documentIsNew = targetHashBeforeApproval === null;
185
+ const denied = await ensurePermission(context, {
186
+ bucket: documentIsNew ? 'create' : 'edit',
187
+ title: documentIsNew ? 'Approve new document?' : 'Approve document edit?',
188
+ body: 'Review changes before applying.',
189
+ filePath: targetPath,
190
+ diff: preview,
191
+ }, 'replace_document_text', { filePath: targetPath });
192
+ if (denied) {
193
+ if (!isTuiMode()) {
194
+ console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
199
195
  }
196
+ return denied;
200
197
  }
201
198
  const fileData = String(serverResult.fileData ?? '');
199
+ if (getCurrentFileHash(context.rootDir, targetPath) !== targetHashBeforeApproval) {
200
+ return {
201
+ ok: false,
202
+ filePath: targetPath,
203
+ failureCategory: 'conflict',
204
+ error: `replace_document_text refused: ${targetPath} changed on disk while the approval prompt was open.`,
205
+ failureDetails: {
206
+ category: 'conflict',
207
+ tool: 'replace_document_text',
208
+ action: 'Re-read the document to see its current contents, then rebuild the replacements against it and retry.',
209
+ },
210
+ };
211
+ }
202
212
  const nextData = Buffer.from(fileData, 'base64');
203
213
  const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
204
214
  const failedCount = Number(serverResult.failedCount ?? 0);
@@ -54,7 +54,6 @@ export async function restoreToCheckpoint(context, args) {
54
54
  const result = await restoreCheckpointFiles({
55
55
  state: context.safety,
56
56
  rootDir: context.rootDir,
57
- projectIndex: context.projectIndex,
58
57
  checkpointId,
59
58
  filePaths,
60
59
  currentTurnId: context.currentTurnId ?? null,