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

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 (38) 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 +232 -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/executor.js +1 -1
  11. package/dist/src/help-text.js +51 -11
  12. package/dist/src/permissions.js +243 -0
  13. package/dist/src/project-index.js +13 -1
  14. package/dist/src/session-store.js +57 -20
  15. package/dist/src/session.js +14 -3
  16. package/dist/src/tool-executor.js +2 -2
  17. package/dist/src/tools/delete-file.js +14 -0
  18. package/dist/src/tools/patch-file.js +12 -16
  19. package/dist/src/tools/replace-document-text.js +28 -18
  20. package/dist/src/tools/run-command.js +13 -27
  21. package/dist/src/tools/run-node-script.js +11 -26
  22. package/dist/src/tools/str-replace.js +12 -16
  23. package/dist/src/tools/write-file.js +66 -0
  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 +569 -151
  27. package/dist/src/ui/tui/bridge.js +10 -0
  28. package/dist/src/ui/tui/build-frame.js +535 -159
  29. package/dist/src/ui/tui/markdown-render.js +81 -73
  30. package/dist/src/ui/tui/shell-input.js +155 -45
  31. package/dist/src/ui/tui/terminal-theme.js +28 -0
  32. package/dist/src/ui/tui/terminal-title.js +3 -0
  33. package/dist/src/ui/tui/terminal-writes.js +48 -0
  34. package/dist/src/ui/tui/text.js +158 -4
  35. package/dist/src/ui/tui/user-input.js +568 -0
  36. package/dist/src/utils.js +9 -0
  37. package/package.json +18 -6
  38. package/dist/src/markdown-renderer.js +0 -112
@@ -0,0 +1,243 @@
1
+ import { getUnquotedShellText } from './agent-mode.js';
2
+ export const PERMISSION_BUCKETS = ['create', 'edit', 'delete', 'run'];
3
+ export function createSessionGrants() {
4
+ return { buckets: [], commandPrefixes: [] };
5
+ }
6
+ export function bucketActionLabel(bucket) {
7
+ if (bucket === 'create')
8
+ return 'create files';
9
+ if (bucket === 'edit')
10
+ return 'edit files';
11
+ if (bucket === 'delete')
12
+ return 'delete files';
13
+ return 'run commands';
14
+ }
15
+ const NO_PREFIX_GRANT_BINARIES = new Set([
16
+ 'rm',
17
+ 'rmdir',
18
+ 'mv',
19
+ 'dd',
20
+ 'mkfs',
21
+ 'shred',
22
+ 'sudo',
23
+ 'doas',
24
+ 'su',
25
+ 'chmod',
26
+ 'chown',
27
+ 'chgrp',
28
+ 'kill',
29
+ 'pkill',
30
+ 'killall',
31
+ 'shutdown',
32
+ 'reboot',
33
+ 'curl',
34
+ 'wget',
35
+ 'ssh',
36
+ 'scp',
37
+ 'nc',
38
+ 'eval',
39
+ 'exec',
40
+ 'source',
41
+ ]);
42
+ const INTERPRETER_OR_WRAPPER_BINARIES = new Set([
43
+ 'bash',
44
+ 'sh',
45
+ 'zsh',
46
+ 'fish',
47
+ 'dash',
48
+ 'ksh',
49
+ 'csh',
50
+ 'tcsh',
51
+ 'python',
52
+ 'python2',
53
+ 'python3',
54
+ 'node',
55
+ 'deno',
56
+ 'bun',
57
+ 'ruby',
58
+ 'perl',
59
+ 'php',
60
+ 'lua',
61
+ 'osascript',
62
+ 'awk',
63
+ 'gawk',
64
+ 'sed',
65
+ 'env',
66
+ 'timeout',
67
+ 'xargs',
68
+ 'nohup',
69
+ 'setsid',
70
+ 'watch',
71
+ 'script',
72
+ 'nice',
73
+ 'ionice',
74
+ 'stdbuf',
75
+ 'time',
76
+ 'sudo',
77
+ 'doas',
78
+ 'su',
79
+ 'find',
80
+ 'make',
81
+ ]);
82
+ const SCRIPT_RUNNER_VERBS = new Set(['run', 'exec', 'run-script', 'x', 'dlx']);
83
+ const VERB_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]*$/;
84
+ const BINARY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
85
+ const SHELL_CONTROL_OPERATOR_PATTERN = /[&|;<>\n]/;
86
+ const COMMAND_SUBSTITUTION_PATTERN = /`|\$\(/;
87
+ function normalizeCommand(command) {
88
+ return String(command ?? '').trim().replace(/\s+/g, ' ');
89
+ }
90
+ function parseCommand(command) {
91
+ const raw = String(command ?? '');
92
+ if (!raw.trim())
93
+ return null;
94
+ if (COMMAND_SUBSTITUTION_PATTERN.test(raw))
95
+ return null;
96
+ if (SHELL_CONTROL_OPERATOR_PATTERN.test(getUnquotedShellText(raw)))
97
+ return null;
98
+ const all = normalizeCommand(raw).match(/\S+/g) ?? [];
99
+ const tokens = [];
100
+ for (const token of all) {
101
+ if (!tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
102
+ continue;
103
+ if (!tokens.length && token === 'command')
104
+ continue;
105
+ tokens.push(token);
106
+ }
107
+ if (!tokens.length)
108
+ return null;
109
+ const binary = (tokens[0].split('/').pop() ?? '').trim();
110
+ if (!binary)
111
+ return null;
112
+ tokens[0] = binary;
113
+ return { canonical: tokens.join(' '), tokens };
114
+ }
115
+ function grantPrefixFromTokens(tokens) {
116
+ const binary = tokens[0];
117
+ if (!BINARY_PATTERN.test(binary))
118
+ return null;
119
+ if (NO_PREFIX_GRANT_BINARIES.has(binary))
120
+ return null;
121
+ if (INTERPRETER_OR_WRAPPER_BINARIES.has(binary))
122
+ return null;
123
+ const verb = tokens[1];
124
+ if (!verb || !VERB_PATTERN.test(verb))
125
+ return null;
126
+ if (SCRIPT_RUNNER_VERBS.has(verb)) {
127
+ const script = tokens[2];
128
+ if (!script || !VERB_PATTERN.test(script))
129
+ return null;
130
+ return `${binary} ${verb} ${script}`;
131
+ }
132
+ return `${binary} ${verb}`;
133
+ }
134
+ export function commandGrantPrefix(command) {
135
+ const parsed = parseCommand(command);
136
+ return parsed ? grantPrefixFromTokens(parsed.tokens) : null;
137
+ }
138
+ export function isBucketGranted(grants, bucket) {
139
+ return Boolean(grants?.buckets.includes(bucket));
140
+ }
141
+ export function isCommandGranted(grants, command) {
142
+ if (!grants)
143
+ return false;
144
+ if (grants.buckets.includes('run'))
145
+ return true;
146
+ if (!grants.commandPrefixes.length)
147
+ return false;
148
+ const parsed = parseCommand(command);
149
+ if (!parsed)
150
+ return false;
151
+ if (grantPrefixFromTokens(parsed.tokens) === null)
152
+ return false;
153
+ return grants.commandPrefixes.some((prefix) => parsed.canonical === prefix || parsed.canonical.startsWith(`${prefix} `));
154
+ }
155
+ export function grantBucket(grants, bucket) {
156
+ if (!grants.buckets.includes(bucket))
157
+ grants.buckets.push(bucket);
158
+ }
159
+ export function grantCommandPrefix(grants, prefix) {
160
+ const normalized = normalizeCommand(prefix);
161
+ if (!normalized)
162
+ return;
163
+ if (!grants.commandPrefixes.includes(normalized)) {
164
+ grants.commandPrefixes.push(normalized);
165
+ }
166
+ }
167
+ export function buildPermissionOptions(bucket, command) {
168
+ const options = [
169
+ { label: 'Approve once', decision: { kind: 'once' } },
170
+ ];
171
+ const prefix = bucket === 'run' && command ? commandGrantPrefix(command) : null;
172
+ if (prefix) {
173
+ options.push({
174
+ label: `Always allow: ${prefix}`,
175
+ decision: { kind: 'always-prefix', prefix },
176
+ });
177
+ }
178
+ options.push({
179
+ label: `Always allow: ${bucketActionLabel(bucket)}`,
180
+ decision: { kind: 'always-bucket' },
181
+ });
182
+ options.push({ label: 'Deny', decision: { kind: 'deny' } });
183
+ return options;
184
+ }
185
+ function declinedSubject(bucket) {
186
+ if (bucket === 'run') {
187
+ return { noun: 'command', effect: 'Nothing was executed' };
188
+ }
189
+ if (bucket === 'create') {
190
+ return { noun: 'file creation', effect: 'Nothing was created' };
191
+ }
192
+ if (bucket === 'delete') {
193
+ return { noun: 'file deletion', effect: 'Nothing was deleted' };
194
+ }
195
+ return { noun: 'edit', effect: 'Nothing was changed' };
196
+ }
197
+ function declinedResponse(bucket, toolName, extra) {
198
+ const { noun, effect } = declinedSubject(bucket);
199
+ return {
200
+ ok: false,
201
+ skipped: true,
202
+ ...extra,
203
+ failureCategory: 'user_declined',
204
+ failureDetails: {
205
+ category: 'user_declined',
206
+ tool: toolName,
207
+ 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.',
208
+ },
209
+ error: `The real user rejected this proposed ${noun}. ${effect}; this was not a tool failure or an automated system skip.`,
210
+ };
211
+ }
212
+ export async function ensurePermission(context, request, toolName, extra = {}) {
213
+ if (context.autoYes)
214
+ return null;
215
+ if (isBucketGranted(context.grants, request.bucket))
216
+ return null;
217
+ if (request.bucket === 'run' &&
218
+ request.command &&
219
+ isCommandGranted(context.grants, request.command)) {
220
+ return null;
221
+ }
222
+ if (!context.requestPermission) {
223
+ return {
224
+ ok: false,
225
+ ...extra,
226
+ error: `requestPermission is required when autoYes is false (${toolName})`,
227
+ };
228
+ }
229
+ const options = buildPermissionOptions(request.bucket, request.command);
230
+ const decision = await context.requestPermission({ ...request, options });
231
+ if (decision.kind === 'deny') {
232
+ return declinedResponse(request.bucket, toolName, extra);
233
+ }
234
+ if (context.grants) {
235
+ if (decision.kind === 'always-bucket') {
236
+ grantBucket(context.grants, request.bucket);
237
+ }
238
+ else if (decision.kind === 'always-prefix') {
239
+ grantCommandPrefix(context.grants, decision.prefix);
240
+ }
241
+ }
242
+ return null;
243
+ }
@@ -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,14 @@
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';
7
+ import { createSessionGrants } from './permissions.js';
6
8
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
7
- import { truncate } from './utils.js';
9
+ import { singleLinePreview } from './utils.js';
8
10
  const SESSION_STORE_VERSION = 1;
9
- const MAX_RECENT_SESSIONS = 5;
11
+ export const MAX_RECENT_SESSIONS = 10;
10
12
  function cloneJson(value) {
11
13
  return JSON.parse(JSON.stringify(value ?? null));
12
14
  }
@@ -77,6 +79,12 @@ function listSessionFiles(rootDir, env = process.env) {
77
79
  .filter((name) => name.endsWith('.json'))
78
80
  .map((name) => path.join(dir, name));
79
81
  }
82
+ function normalizeBranch(value) {
83
+ const text = String(value ?? '')
84
+ .replace(/[\r\n\t]/g, ' ')
85
+ .trim();
86
+ return text ? text.slice(0, 120) : null;
87
+ }
80
88
  function normalizeHistory(value) {
81
89
  if (!Array.isArray(value))
82
90
  return [];
@@ -107,6 +115,7 @@ function normalizeSnapshot(raw, rootDir) {
107
115
  createdAt: normalizeIsoDate(raw.createdAt),
108
116
  updatedAt: normalizeIsoDate(raw.updatedAt),
109
117
  modelId,
118
+ branch: normalizeBranch(raw.branch),
110
119
  history: cloneJson(normalizeHistory(raw.history)),
111
120
  clientState: sanitizeClientState(raw.clientState),
112
121
  serverState: sanitizeOpaqueState(raw.serverState),
@@ -154,6 +163,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
154
163
  }
155
164
  }
156
165
  }
166
+ export function readGitBranch(rootDir) {
167
+ try {
168
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
169
+ cwd: rootDir,
170
+ encoding: 'utf8',
171
+ timeout: 1500,
172
+ stdio: ['ignore', 'pipe', 'ignore'],
173
+ });
174
+ const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
175
+ return branch && branch !== 'HEAD' ? branch : null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
157
181
  export function snapshotFromSession(session) {
158
182
  const now = new Date().toISOString();
159
183
  const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
@@ -168,6 +192,7 @@ export function snapshotFromSession(session) {
168
192
  createdAt,
169
193
  updatedAt: now,
170
194
  modelId: session.modelId,
195
+ branch: readGitBranch(session.rootDir),
171
196
  history: cloneJson(session.history),
172
197
  clientState: {
173
198
  editCounter: session.clientState.editCounter,
@@ -187,6 +212,7 @@ export function saveSessionState(session, env = process.env) {
187
212
  }
188
213
  export function applySessionSnapshot(session, snapshot, options = {}) {
189
214
  const currentAgentMode = session.agentMode;
215
+ const previousSessionId = session.sessionId;
190
216
  session.sessionId = snapshot.id;
191
217
  session.sessionName = snapshot.name;
192
218
  session.sessionCreatedAt = snapshot.createdAt;
@@ -196,6 +222,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
196
222
  session.serverState = sanitizeOpaqueState(snapshot.serverState);
197
223
  session.agentMode = options.preserveAgentMode ? currentAgentMode : 'default';
198
224
  session.autoYes = session.agentMode === 'auto-accept';
225
+ if (previousSessionId !== session.sessionId) {
226
+ session.grants = createSessionGrants();
227
+ }
199
228
  session.turnState = {
200
229
  id: null,
201
230
  historyStartIndex: session.history.length,
@@ -210,6 +239,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
210
239
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
211
240
  };
212
241
  }
242
+ export function sessionHasUserMessage(session) {
243
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
244
+ }
213
245
  function extractMarkedSection(text, marker) {
214
246
  const index = text.indexOf(marker);
215
247
  if (index === -1)
@@ -218,23 +250,27 @@ function extractMarkedSection(text, marker) {
218
250
  const end = after.indexOf('\n\n');
219
251
  return (end === -1 ? after : after.slice(0, end)).trim();
220
252
  }
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);
253
+ export function userPromptText(entry) {
254
+ if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
255
+ return '';
256
+ const text = (entry.parts ?? [])
257
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
258
+ .filter(Boolean)
259
+ .join('\n')
260
+ .trim();
261
+ if (!text)
262
+ return '';
263
+ const request = extractMarkedSection(text, 'Current user request:') ||
264
+ extractMarkedSection(text, 'User request:');
265
+ const message = extractMarkedSection(text, 'Current user message:') ||
266
+ extractMarkedSection(text, 'User message:');
267
+ return (request || message || text).trim();
268
+ }
269
+ function extractFirstUserPrompt(history) {
270
+ for (const entry of history) {
271
+ const text = userPromptText(entry);
272
+ if (text)
273
+ return singleLinePreview(text, 120);
238
274
  }
239
275
  return '';
240
276
  }
@@ -247,8 +283,9 @@ function metadataFromSnapshot(snapshot) {
247
283
  updatedAt: snapshot.updatedAt,
248
284
  modelId: snapshot.modelId,
249
285
  messageCount: snapshot.history.length,
250
- lastUserMessage: extractLastUserMessage(snapshot.history),
286
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
251
287
  summaryPreview: '',
288
+ branch: snapshot.branch ?? null,
252
289
  };
253
290
  }
254
291
  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) {
@@ -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);
@@ -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);