@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.
- package/README.md +32 -4
- package/dist/bin/ai.js +57 -291
- package/dist/src/agent-mode.js +1 -1
- package/dist/src/api/auth.js +2 -2
- package/dist/src/api/browser-login.js +72 -3
- package/dist/src/api/chat.js +236 -33
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/http.js +16 -3
- package/dist/src/api/models.js +9 -4
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/executor.js +1 -1
- package/dist/src/help-text.js +51 -11
- package/dist/src/permissions.js +243 -0
- package/dist/src/project-index.js +13 -1
- package/dist/src/session-store.js +119 -20
- package/dist/src/session.js +14 -3
- package/dist/src/tool-executor.js +2 -2
- package/dist/src/tools/delete-file.js +14 -0
- package/dist/src/tools/index.js +2 -0
- package/dist/src/tools/patch-file.js +12 -16
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/run-command.js +13 -27
- package/dist/src/tools/run-node-script.js +11 -26
- package/dist/src/tools/str-replace.js +12 -16
- package/dist/src/tools/write-file.js +66 -0
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +579 -154
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +535 -159
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +206 -63
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +18 -6
- 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
|
|
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 {
|
|
10
|
+
import { singleLinePreview } from './utils.js';
|
|
8
11
|
const SESSION_STORE_VERSION = 1;
|
|
9
|
-
const MAX_RECENT_SESSIONS =
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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) {
|
package/dist/src/session.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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) {
|
package/dist/src/tools/index.js
CHANGED
|
@@ -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
|
|
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 (!
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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);
|