@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21

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 (44) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +236 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/core/clipboard.js +7 -13
  11. package/dist/src/core/image-limits.js +56 -0
  12. package/dist/src/core/image-path-extractor.js +70 -3
  13. package/dist/src/core/session-image-store.js +199 -0
  14. package/dist/src/executor.js +1 -1
  15. package/dist/src/help-text.js +51 -11
  16. package/dist/src/permissions.js +243 -0
  17. package/dist/src/project-index.js +13 -1
  18. package/dist/src/session-store.js +119 -20
  19. package/dist/src/session.js +14 -3
  20. package/dist/src/tool-executor.js +2 -2
  21. package/dist/src/tools/delete-file.js +14 -0
  22. package/dist/src/tools/index.js +2 -0
  23. package/dist/src/tools/patch-file.js +12 -16
  24. package/dist/src/tools/read-image-file.js +85 -0
  25. package/dist/src/tools/replace-document-text.js +28 -18
  26. package/dist/src/tools/run-command.js +13 -27
  27. package/dist/src/tools/run-node-script.js +11 -26
  28. package/dist/src/tools/str-replace.js +12 -16
  29. package/dist/src/tools/write-file.js +66 -0
  30. package/dist/src/turn-failure-marker.js +11 -0
  31. package/dist/src/ui/prompt-history-store.js +1 -1
  32. package/dist/src/ui/repl.js +579 -154
  33. package/dist/src/ui/tui/bridge.js +10 -0
  34. package/dist/src/ui/tui/build-frame.js +535 -159
  35. package/dist/src/ui/tui/markdown-render.js +81 -73
  36. package/dist/src/ui/tui/shell-input.js +206 -63
  37. package/dist/src/ui/tui/terminal-theme.js +28 -0
  38. package/dist/src/ui/tui/terminal-title.js +3 -0
  39. package/dist/src/ui/tui/terminal-writes.js +48 -0
  40. package/dist/src/ui/tui/text.js +158 -4
  41. package/dist/src/ui/tui/user-input.js +568 -0
  42. package/dist/src/utils.js +9 -0
  43. package/package.json +18 -6
  44. package/dist/src/markdown-renderer.js +0 -112
@@ -39,10 +39,21 @@ function removeFile(index, relPath) {
39
39
  index.chunksByFile.delete(relPath);
40
40
  index.fileSignatures.delete(relPath);
41
41
  }
42
+ function countIndexedChunks(index) {
43
+ return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
44
+ }
42
45
  async function initializeIndex(index) {
43
46
  if (index.initialized) {
44
- return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
47
+ return countIndexedChunks(index);
48
+ }
49
+ if (!index._initializing) {
50
+ index._initializing = scanProjectIntoIndex(index).finally(() => {
51
+ index._initializing = null;
52
+ });
45
53
  }
54
+ return index._initializing;
55
+ }
56
+ async function scanProjectIntoIndex(index) {
46
57
  const files = listProjectFiles(index.rootDir);
47
58
  const chunks = await scanFiles(index.rootDir, files);
48
59
  index.fileSignatures.clear();
@@ -106,6 +117,7 @@ export function createIndex({ rootDir, onStatus = null, onContextLog = null, })
106
117
  return {
107
118
  rootDir: path.resolve(rootDir),
108
119
  initialized: false,
120
+ _initializing: null,
109
121
  fileSignatures: new Map(),
110
122
  chunksByFile: new Map(),
111
123
  onStatus,
@@ -1,12 +1,15 @@
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';
6
+ import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
5
7
  import { normalizeAssistantEditJournal } from './edit-journal.js';
8
+ import { createSessionGrants } from './permissions.js';
6
9
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
7
- import { truncate } from './utils.js';
10
+ import { singleLinePreview } from './utils.js';
8
11
  const SESSION_STORE_VERSION = 1;
9
- const MAX_RECENT_SESSIONS = 5;
12
+ export const MAX_RECENT_SESSIONS = 10;
10
13
  function cloneJson(value) {
11
14
  return JSON.parse(JSON.stringify(value ?? null));
12
15
  }
@@ -77,6 +80,45 @@ function listSessionFiles(rootDir, env = process.env) {
77
80
  .filter((name) => name.endsWith('.json'))
78
81
  .map((name) => path.join(dir, name));
79
82
  }
83
+ function normalizeBranch(value) {
84
+ const text = String(value ?? '')
85
+ .replace(/[\r\n\t]/g, ' ')
86
+ .trim();
87
+ return text ? text.slice(0, 120) : null;
88
+ }
89
+ function dehydrateHistoryImages(history) {
90
+ return history.map((entry) => ({
91
+ ...entry,
92
+ parts: (entry.parts ?? []).map((part) => {
93
+ if (!part?.inlineData?.data)
94
+ return part;
95
+ const cachePath = part.imageCachePath ?? findStoredImageByContent(part.inlineData.data);
96
+ if (!cachePath)
97
+ return part;
98
+ return {
99
+ imageRef: { cachePath, mimeType: part.inlineData.mimeType },
100
+ text: '[image stored in this session]',
101
+ };
102
+ }),
103
+ }));
104
+ }
105
+ function rehydrateHistoryImages(history) {
106
+ return history.map((entry) => ({
107
+ ...entry,
108
+ parts: (entry.parts ?? []).map((part) => {
109
+ const ref = part?.imageRef;
110
+ if (!ref?.cachePath)
111
+ return part;
112
+ const stored = readSessionImage(String(ref.cachePath));
113
+ if (!stored)
114
+ return { text: part.text ?? '[image no longer available]' };
115
+ return {
116
+ inlineData: { mimeType: stored.mimeType, data: stored.base64Data },
117
+ imageCachePath: ref.cachePath,
118
+ };
119
+ }),
120
+ }));
121
+ }
80
122
  function normalizeHistory(value) {
81
123
  if (!Array.isArray(value))
82
124
  return [];
@@ -107,6 +149,7 @@ function normalizeSnapshot(raw, rootDir) {
107
149
  createdAt: normalizeIsoDate(raw.createdAt),
108
150
  updatedAt: normalizeIsoDate(raw.updatedAt),
109
151
  modelId,
152
+ branch: normalizeBranch(raw.branch),
110
153
  history: cloneJson(normalizeHistory(raw.history)),
111
154
  clientState: sanitizeClientState(raw.clientState),
112
155
  serverState: sanitizeOpaqueState(raw.serverState),
@@ -145,14 +188,57 @@ function assertSessionNameAvailable(rootDir, name, sessionId, env = process.env)
145
188
  throw new Error(`Session name "${name}" is already used by ${duplicate.id}. Use --session "${name}" to resume it or choose a different name.`);
146
189
  }
147
190
  }
191
+ function listAllSessionIds(env = process.env) {
192
+ const ids = new Set();
193
+ const projectsDir = path.join(getSessionBaseDir(env), 'sessions', 'projects');
194
+ if (!existsSync(projectsDir))
195
+ return ids;
196
+ try {
197
+ for (const project of readdirSync(projectsDir)) {
198
+ const dir = path.join(projectsDir, project);
199
+ try {
200
+ for (const file of readdirSync(dir)) {
201
+ if (file.endsWith('.json'))
202
+ ids.add(path.basename(file, '.json'));
203
+ }
204
+ }
205
+ catch {
206
+ }
207
+ }
208
+ }
209
+ catch {
210
+ return ids;
211
+ }
212
+ return ids;
213
+ }
148
214
  export function pruneSavedSessions(rootDir, env = process.env) {
149
215
  const snapshots = loadAllSnapshots(rootDir, env);
150
216
  const keep = new Set(snapshots.slice(0, MAX_RECENT_SESSIONS).map((snapshot) => snapshot.id));
151
217
  for (const snapshot of snapshots.slice(MAX_RECENT_SESSIONS)) {
152
218
  if (!keep.has(snapshot.id)) {
153
219
  rmSync(getSessionPath(rootDir, snapshot.id, env), { force: true });
220
+ pruneSessionImages(snapshot.id, env);
154
221
  }
155
222
  }
223
+ sweepOrphanSessionImages({
224
+ activeSessionIds: listAllSessionIds(env),
225
+ env,
226
+ });
227
+ }
228
+ export function readGitBranch(rootDir) {
229
+ try {
230
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
231
+ cwd: rootDir,
232
+ encoding: 'utf8',
233
+ timeout: 1500,
234
+ stdio: ['ignore', 'pipe', 'ignore'],
235
+ });
236
+ const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
237
+ return branch && branch !== 'HEAD' ? branch : null;
238
+ }
239
+ catch {
240
+ return null;
241
+ }
156
242
  }
157
243
  export function snapshotFromSession(session) {
158
244
  const now = new Date().toISOString();
@@ -168,6 +254,7 @@ export function snapshotFromSession(session) {
168
254
  createdAt,
169
255
  updatedAt: now,
170
256
  modelId: session.modelId,
257
+ branch: readGitBranch(session.rootDir),
171
258
  history: cloneJson(session.history),
172
259
  clientState: {
173
260
  editCounter: session.clientState.editCounter,
@@ -187,6 +274,7 @@ export function saveSessionState(session, env = process.env) {
187
274
  }
188
275
  export function applySessionSnapshot(session, snapshot, options = {}) {
189
276
  const currentAgentMode = session.agentMode;
277
+ const previousSessionId = session.sessionId;
190
278
  session.sessionId = snapshot.id;
191
279
  session.sessionName = snapshot.name;
192
280
  session.sessionCreatedAt = snapshot.createdAt;
@@ -196,6 +284,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
196
284
  session.serverState = sanitizeOpaqueState(snapshot.serverState);
197
285
  session.agentMode = options.preserveAgentMode ? currentAgentMode : 'default';
198
286
  session.autoYes = session.agentMode === 'auto-accept';
287
+ if (previousSessionId !== session.sessionId) {
288
+ session.grants = createSessionGrants();
289
+ }
199
290
  session.turnState = {
200
291
  id: null,
201
292
  historyStartIndex: session.history.length,
@@ -210,6 +301,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
210
301
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
211
302
  };
212
303
  }
304
+ export function sessionHasUserMessage(session) {
305
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
306
+ }
213
307
  function extractMarkedSection(text, marker) {
214
308
  const index = text.indexOf(marker);
215
309
  if (index === -1)
@@ -218,23 +312,27 @@ function extractMarkedSection(text, marker) {
218
312
  const end = after.indexOf('\n\n');
219
313
  return (end === -1 ? after : after.slice(0, end)).trim();
220
314
  }
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);
315
+ export function userPromptText(entry) {
316
+ if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
317
+ return '';
318
+ const text = (entry.parts ?? [])
319
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
320
+ .filter(Boolean)
321
+ .join('\n')
322
+ .trim();
323
+ if (!text)
324
+ return '';
325
+ const request = extractMarkedSection(text, 'Current user request:') ||
326
+ extractMarkedSection(text, 'User request:');
327
+ const message = extractMarkedSection(text, 'Current user message:') ||
328
+ extractMarkedSection(text, 'User message:');
329
+ return (request || message || text).trim();
330
+ }
331
+ function extractFirstUserPrompt(history) {
332
+ for (const entry of history) {
333
+ const text = userPromptText(entry);
334
+ if (text)
335
+ return singleLinePreview(text, 120);
238
336
  }
239
337
  return '';
240
338
  }
@@ -247,8 +345,9 @@ function metadataFromSnapshot(snapshot) {
247
345
  updatedAt: snapshot.updatedAt,
248
346
  modelId: snapshot.modelId,
249
347
  messageCount: snapshot.history.length,
250
- lastUserMessage: extractLastUserMessage(snapshot.history),
348
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
251
349
  summaryPreview: '',
350
+ branch: snapshot.branch ?? null,
252
351
  };
253
352
  }
254
353
  export function listSessionMetadata(rootDir, env = process.env) {
@@ -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,
@@ -277,8 +277,8 @@ export async function executeLocalToolCall(toolContext, session, call) {
277
277
  sessionId: session.sessionId,
278
278
  projectIndex: toolContext.projectIndex,
279
279
  autoYes: session.autoYes,
280
- confirmCommand: session.confirmCommand,
281
- confirmPatch: session.confirmPatch,
280
+ grants: session.grants,
281
+ requestPermission: session.requestPermission,
282
282
  requestSudoPassword: session.requestSudoPassword,
283
283
  onStatus: session.onStatus,
284
284
  editJournal: session.clientState.editJournal,
@@ -3,6 +3,7 @@ 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';
6
+ import { ensurePermission } from '../permissions.js';
6
7
  export async function deleteFile(context, args) {
7
8
  const { rootDir, projectIndex } = context;
8
9
  const filePath = String(args.filePath ?? '').trim();
@@ -24,6 +25,19 @@ export async function deleteFile(context, args) {
24
25
  };
25
26
  }
26
27
  const scratchPath = pathKind === 'scratch';
28
+ if (!scratchPath) {
29
+ const denied = await ensurePermission(context, {
30
+ bucket: 'delete',
31
+ title: 'Approve file deletion?',
32
+ body: `Delete ${filePath}`,
33
+ filePath,
34
+ }, 'delete_file', { filePath });
35
+ if (denied) {
36
+ if (!isTuiMode())
37
+ console.log(chalk.dim(` ⏭ Delete skipped: ${filePath}`));
38
+ return denied;
39
+ }
40
+ }
27
41
  const result = deleteProjectFile(rootDir, filePath);
28
42
  if (result.deleted) {
29
43
  if (!scratchPath) {
@@ -22,6 +22,7 @@ import { shellJobKill } from './shell-job-kill.js';
22
22
  import { strReplace } from './str-replace.js';
23
23
  import { undoEdit } from './undo-edit.js';
24
24
  import { updateTodos } from './update-todos.js';
25
+ import { readImageFile } from './read-image-file.js';
25
26
  import { writeFile } from './write-file.js';
26
27
  export const TOOL_MAP = {
27
28
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -50,6 +51,7 @@ export const TOOL_MAP = {
50
51
  shell_job_output: shellJobOutput,
51
52
  shell_job_kill: shellJobKill,
52
53
  update_todos: updateTodos,
54
+ analyze_image: (context, args) => readImageFile(context, args),
53
55
  };
54
56
  function invalidToolCall(error) {
55
57
  return {
@@ -7,9 +7,10 @@ import { repairFilePath } from './path-suggest.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
8
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
10
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
12
  export async function patchFile(context, args) {
12
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
13
+ const { rootDir, projectIndex } = context;
13
14
  const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
14
15
  let patch = typeof args.patch === 'string' ? args.patch : '';
15
16
  if (!filePath) {
@@ -74,23 +75,18 @@ export async function patchFile(context, args) {
74
75
  };
75
76
  }
76
77
  renderDiffPreview(filePath, patch);
77
- if (!autoYes && confirmPatch) {
78
- const confirmed = await confirmPatch(filePath, patch);
79
- if (!confirmed) {
78
+ if (!scratchPath) {
79
+ const denied = await ensurePermission(context, {
80
+ bucket: getCurrentFileHash(rootDir, filePath) === null ? 'create' : 'edit',
81
+ title: 'Approve patch?',
82
+ body: 'Review changes before applying.',
83
+ filePath,
84
+ diff: patch,
85
+ }, 'patch_file', { filePath });
86
+ if (denied) {
80
87
  if (!isTuiMode())
81
88
  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
- };
89
+ return denied;
94
90
  }
95
91
  }
96
92
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
@@ -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);
@@ -2,13 +2,14 @@ import chalk from '../colors.js';
2
2
  import { startBackgroundJob } from '../background-jobs.js';
3
3
  import { getBlockedCommandReason, runCommand, } from '../executor.js';
4
4
  import { syncIndexFromDisk } from '../project-index.js';
5
+ import { ensurePermission } from '../permissions.js';
5
6
  import { isTuiMode } from '../runtime-mode.js';
6
7
  import { redactConnectionStringCredentials } from '../secret-preview.js';
7
8
  import { buildNestedGitHint } from '../session-safety.js';
8
9
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
9
10
  const MAX_OUTPUT_CHARS = 4000;
10
11
  export async function runShellCommand(context, args) {
11
- const { rootDir, projectIndex, autoYes, confirmCommand, requestSudoPassword, onStatus, } = context;
12
+ const { rootDir, projectIndex, requestSudoPassword, onStatus, } = context;
12
13
  const command = String(args.command ?? '').trim();
13
14
  if (!command) {
14
15
  return { ok: false, error: 'command is required' };
@@ -31,33 +32,18 @@ export async function runShellCommand(context, args) {
31
32
  }
32
33
  if (!isTuiMode())
33
34
  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
35
+ const denied = await ensurePermission(context, {
36
+ bucket: 'run',
37
+ title: 'Approve command?',
38
+ body: runInBackground
43
39
  ? `${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
- }
40
+ : command,
41
+ command,
42
+ }, 'run_command', { command });
43
+ if (denied) {
44
+ if (!isTuiMode())
45
+ console.log(chalk.dim(` ⏭ Skipped: ${command}`));
46
+ return denied;
61
47
  }
62
48
  if (runInBackground) {
63
49
  return runBackgroundCommand(context, command, args.timeout_ms, repoHint);