@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.
- 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 +232 -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/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 +57 -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/patch-file.js +12 -16
- 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 +569 -151
- 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 +155 -45
- 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
|
@@ -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
|
|
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 {
|
|
9
|
+
import { singleLinePreview } from './utils.js';
|
|
8
10
|
const SESSION_STORE_VERSION = 1;
|
|
9
|
-
const MAX_RECENT_SESSIONS =
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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) {
|
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) {
|
|
@@ -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);
|
|
@@ -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);
|