@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10
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 +49 -3
- package/dist/bin/ai.js +83 -197
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +4 -4
- package/dist/src/api/browser-login.js +72 -19
- package/dist/src/api/chat.js +182 -35
- package/dist/src/api/http.js +65 -4
- package/dist/src/api/models.js +33 -22
- package/dist/src/artifact-policy.js +3 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +0 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +19 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +30 -13
- package/dist/src/patcher.js +97 -12
- package/dist/src/project-index.js +13 -1
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scanner.js +50 -12
- package/dist/src/scratch-dir.js +75 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +0 -19
- package/dist/src/session-store.js +52 -21
- package/dist/src/session.js +8 -0
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +194 -21
- package/dist/src/tools/delete-file.js +23 -5
- package/dist/src/tools/index.js +6 -0
- package/dist/src/tools/patch-file.js +33 -7
- package/dist/src/tools/path-suggest.js +81 -8
- package/dist/src/tools/read-document.js +2 -2
- package/dist/src/tools/read-file.js +17 -8
- package/dist/src/tools/replace-document-text.js +10 -12
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +109 -24
- package/dist/src/tools/run-node-script.js +27 -5
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/tools/str-replace.js +33 -7
- package/dist/src/tools/undo-edit.js +1 -1
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +26 -6
- package/dist/src/tree-sitter-runtime.js +8 -1
- 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 +500 -71
- package/dist/src/ui/tui/bridge.js +3 -4
- package/dist/src/ui/tui/build-frame.js +393 -100
- package/dist/src/ui/tui/markdown-render.js +72 -73
- package/dist/src/ui/tui/shell-input.js +75 -17
- package/dist/src/ui/tui/terminal-title.js +84 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/utils.js +9 -0
- package/dist/src/version.js +0 -6
- package/dist/vendor/web-tree-sitter/LICENSE +21 -0
- package/dist/vendor/web-tree-sitter/NOTICE +13 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +27 -16
- package/dist/src/markdown-renderer.js +0 -112
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const scratchDirs = new Map();
|
|
5
|
+
let activeSessionId = 'default';
|
|
6
|
+
export function setScratchSession(sessionId) {
|
|
7
|
+
activeSessionId = String(sessionId ?? '').trim() || 'default';
|
|
8
|
+
}
|
|
9
|
+
function allocateSessionScratchDir() {
|
|
10
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
11
|
+
scratchDirs.set(activeSessionId, dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
export function sessionScratchDir() {
|
|
15
|
+
return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
|
|
16
|
+
}
|
|
17
|
+
function isOwnedDirectory(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const st = lstatSync(dir);
|
|
20
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
21
|
+
return false;
|
|
22
|
+
if (process.platform !== 'win32' &&
|
|
23
|
+
typeof process.getuid === 'function' &&
|
|
24
|
+
st.uid !== process.getuid()) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function ensureSessionScratchDir() {
|
|
34
|
+
let dir = sessionScratchDir();
|
|
35
|
+
if (!isOwnedDirectory(dir)) {
|
|
36
|
+
dir = allocateSessionScratchDir();
|
|
37
|
+
}
|
|
38
|
+
if (process.platform !== 'win32') {
|
|
39
|
+
chmodSync(dir, 0o700);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function hasUnsafeScratchComponent(root, relativePath) {
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
46
|
+
current = path.join(current, segment);
|
|
47
|
+
try {
|
|
48
|
+
const stat = lstatSync(current);
|
|
49
|
+
if (stat.isSymbolicLink())
|
|
50
|
+
return true;
|
|
51
|
+
if (stat.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error?.code !== 'ENOENT';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
export function isWithinSessionScratchDir(absPath) {
|
|
63
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
64
|
+
const resolved = path.resolve(absPath);
|
|
65
|
+
const relative = path.relative(root, resolved);
|
|
66
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
67
|
+
}
|
|
68
|
+
export function isInsideTheGitAiScratch(absPath) {
|
|
69
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
70
|
+
const resolved = path.resolve(absPath);
|
|
71
|
+
const relative = path.relative(root, resolved);
|
|
72
|
+
if (!isWithinSessionScratchDir(resolved))
|
|
73
|
+
return false;
|
|
74
|
+
return !hasUnsafeScratchComponent(root, relative);
|
|
75
|
+
}
|
|
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
|
|
|
6
6
|
const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
|
|
7
7
|
const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
|
|
8
8
|
const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
|
|
9
|
-
// Password embedded in a connection-string URL, e.g.
|
|
10
|
-
// `postgresql://user:PASS@host`. Redacted from shell output so secrets in
|
|
11
|
-
// commands like `cat .env` do not leak into history or telemetry.
|
|
12
9
|
const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
|
|
13
|
-
/** Redact only userinfo passwords in connection-string URLs (zero false positives). */
|
|
14
10
|
export function redactConnectionStringCredentials(text) {
|
|
15
11
|
return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
|
|
16
12
|
}
|
|
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
|
|
|
72
68
|
const base = path.posix.basename(text.replace(/\\/g, '/'));
|
|
73
69
|
return DOTENV_BASENAME_PATTERN.test(base);
|
|
74
70
|
}
|
|
75
|
-
/**
|
|
76
|
-
* True only for a clean dotenv file we can safely show with keys visible and
|
|
77
|
-
* values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
|
|
78
|
-
* is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
|
|
79
|
-
* raw secret) returns false so the caller keeps the opaque blackout instead.
|
|
80
|
-
*/
|
|
81
71
|
export function looksLikeEditableDotenv(content) {
|
|
82
72
|
PEM_BLOCK_PATTERN.lastIndex = 0;
|
|
83
73
|
if (PEM_BLOCK_PATTERN.test(content))
|
|
@@ -454,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
|
|
|
454
454
|
}
|
|
455
455
|
const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
|
|
456
456
|
const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
|
|
457
|
-
/**
|
|
458
|
-
* Redact a dotenv file's values while leaving keys visible. Every assignment's
|
|
459
|
-
* value is replaced with a stable, reversible token so the agent can see the
|
|
460
|
-
* file's structure and edit it (remove or replace lines) without ever seeing a
|
|
461
|
-
* secret value; `resolveRedactionTokens` swaps the real values back on write.
|
|
462
|
-
* Comment bodies are tokenized too, because developers routinely leave
|
|
463
|
-
* commented-out credentials in dotenv files and those must not leak where the
|
|
464
|
-
* opaque preview would have hidden them. Callers must confirm the content is
|
|
465
|
-
* clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
|
|
466
|
-
* lines reaching here are blanks and comments.
|
|
467
|
-
*/
|
|
468
457
|
export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
469
458
|
const tokens = [];
|
|
470
459
|
const redactedLines = content.split('\n').map((line) => {
|
|
@@ -490,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
|
490
479
|
});
|
|
491
480
|
return { content: redactedLines.join('\n'), tokens };
|
|
492
481
|
}
|
|
493
|
-
/**
|
|
494
|
-
* The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
|
|
495
|
-
* emits more tokens than that would evict its own oldest tokens, leaving
|
|
496
|
-
* `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
|
|
497
|
-
* longer resolve (silently writing the literal token back). So a dotenv file
|
|
498
|
-
* with more tokenizable lines than the budget must not use the editable preview
|
|
499
|
-
* — the caller falls back to the opaque blackout instead.
|
|
500
|
-
*/
|
|
501
482
|
export function dotenvFitsRedactionBudget(content) {
|
|
502
483
|
let count = 0;
|
|
503
484
|
for (const line of content.split('\n')) {
|
|
@@ -1,12 +1,13 @@
|
|
|
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';
|
|
6
7
|
import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
|
|
7
|
-
import {
|
|
8
|
+
import { singleLinePreview } from './utils.js';
|
|
8
9
|
const SESSION_STORE_VERSION = 1;
|
|
9
|
-
const MAX_RECENT_SESSIONS =
|
|
10
|
+
export const MAX_RECENT_SESSIONS = 10;
|
|
10
11
|
function cloneJson(value) {
|
|
11
12
|
return JSON.parse(JSON.stringify(value ?? null));
|
|
12
13
|
}
|
|
@@ -77,6 +78,12 @@ function listSessionFiles(rootDir, env = process.env) {
|
|
|
77
78
|
.filter((name) => name.endsWith('.json'))
|
|
78
79
|
.map((name) => path.join(dir, name));
|
|
79
80
|
}
|
|
81
|
+
function normalizeBranch(value) {
|
|
82
|
+
const text = String(value ?? '')
|
|
83
|
+
.replace(/[\r\n\t]/g, ' ')
|
|
84
|
+
.trim();
|
|
85
|
+
return text ? text.slice(0, 120) : null;
|
|
86
|
+
}
|
|
80
87
|
function normalizeHistory(value) {
|
|
81
88
|
if (!Array.isArray(value))
|
|
82
89
|
return [];
|
|
@@ -107,6 +114,7 @@ function normalizeSnapshot(raw, rootDir) {
|
|
|
107
114
|
createdAt: normalizeIsoDate(raw.createdAt),
|
|
108
115
|
updatedAt: normalizeIsoDate(raw.updatedAt),
|
|
109
116
|
modelId,
|
|
117
|
+
branch: normalizeBranch(raw.branch),
|
|
110
118
|
history: cloneJson(normalizeHistory(raw.history)),
|
|
111
119
|
clientState: sanitizeClientState(raw.clientState),
|
|
112
120
|
serverState: sanitizeOpaqueState(raw.serverState),
|
|
@@ -122,7 +130,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
|
|
|
122
130
|
snapshots.push(loadSnapshotFile(filePath, rootDir));
|
|
123
131
|
}
|
|
124
132
|
catch {
|
|
125
|
-
// Skip corrupted snapshots silently — customers have no actionable debug path here.
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -155,6 +162,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
|
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
}
|
|
165
|
+
export function readGitBranch(rootDir) {
|
|
166
|
+
try {
|
|
167
|
+
const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
168
|
+
cwd: rootDir,
|
|
169
|
+
encoding: 'utf8',
|
|
170
|
+
timeout: 1500,
|
|
171
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
172
|
+
});
|
|
173
|
+
const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
|
|
174
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
158
180
|
export function snapshotFromSession(session) {
|
|
159
181
|
const now = new Date().toISOString();
|
|
160
182
|
const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
|
|
@@ -169,6 +191,7 @@ export function snapshotFromSession(session) {
|
|
|
169
191
|
createdAt,
|
|
170
192
|
updatedAt: now,
|
|
171
193
|
modelId: session.modelId,
|
|
194
|
+
branch: readGitBranch(session.rootDir),
|
|
172
195
|
history: cloneJson(session.history),
|
|
173
196
|
clientState: {
|
|
174
197
|
editCounter: session.clientState.editCounter,
|
|
@@ -211,6 +234,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
|
|
|
211
234
|
safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
|
|
212
235
|
};
|
|
213
236
|
}
|
|
237
|
+
export function sessionHasUserMessage(session) {
|
|
238
|
+
return session.history.some((entry) => Boolean(userPromptText(entry)));
|
|
239
|
+
}
|
|
214
240
|
function extractMarkedSection(text, marker) {
|
|
215
241
|
const index = text.indexOf(marker);
|
|
216
242
|
if (index === -1)
|
|
@@ -219,23 +245,27 @@ function extractMarkedSection(text, marker) {
|
|
|
219
245
|
const end = after.indexOf('\n\n');
|
|
220
246
|
return (end === -1 ? after : after.slice(0, end)).trim();
|
|
221
247
|
}
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
export function userPromptText(entry) {
|
|
249
|
+
if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
|
|
250
|
+
return '';
|
|
251
|
+
const text = (entry.parts ?? [])
|
|
252
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.join('\n')
|
|
255
|
+
.trim();
|
|
256
|
+
if (!text)
|
|
257
|
+
return '';
|
|
258
|
+
const request = extractMarkedSection(text, 'Current user request:') ||
|
|
259
|
+
extractMarkedSection(text, 'User request:');
|
|
260
|
+
const message = extractMarkedSection(text, 'Current user message:') ||
|
|
261
|
+
extractMarkedSection(text, 'User message:');
|
|
262
|
+
return (request || message || text).trim();
|
|
263
|
+
}
|
|
264
|
+
function extractFirstUserPrompt(history) {
|
|
265
|
+
for (const entry of history) {
|
|
266
|
+
const text = userPromptText(entry);
|
|
267
|
+
if (text)
|
|
268
|
+
return singleLinePreview(text, 120);
|
|
239
269
|
}
|
|
240
270
|
return '';
|
|
241
271
|
}
|
|
@@ -248,8 +278,9 @@ function metadataFromSnapshot(snapshot) {
|
|
|
248
278
|
updatedAt: snapshot.updatedAt,
|
|
249
279
|
modelId: snapshot.modelId,
|
|
250
280
|
messageCount: snapshot.history.length,
|
|
251
|
-
lastUserMessage:
|
|
281
|
+
lastUserMessage: extractFirstUserPrompt(snapshot.history),
|
|
252
282
|
summaryPreview: '',
|
|
283
|
+
branch: snapshot.branch ?? null,
|
|
253
284
|
};
|
|
254
285
|
}
|
|
255
286
|
export function listSessionMetadata(rootDir, env = process.env) {
|
package/dist/src/session.js
CHANGED
|
@@ -69,6 +69,14 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
|
|
|
69
69
|
serverState: cloneOpaqueState(serverState),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
export function startNewConversation(session) {
|
|
73
|
+
clearConversation(session);
|
|
74
|
+
const createdAt = new Date().toISOString();
|
|
75
|
+
session.sessionId = createSessionId();
|
|
76
|
+
session.sessionName = null;
|
|
77
|
+
session.sessionCreatedAt = createdAt;
|
|
78
|
+
session.sessionUpdatedAt = createdAt;
|
|
79
|
+
}
|
|
72
80
|
export function clearConversation(session) {
|
|
73
81
|
session.history = [];
|
|
74
82
|
session.serverState = preserveProviderSelection(session.serverState);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const MAX_TODO_ITEMS = 20;
|
|
2
|
+
export const MAX_TODO_TEXT_CHARS = 160;
|
|
3
|
+
let items = [];
|
|
4
|
+
let activeSessionId = null;
|
|
5
|
+
const STATUS_ALIASES = {
|
|
6
|
+
pending: 'pending',
|
|
7
|
+
todo: 'pending',
|
|
8
|
+
not_started: 'pending',
|
|
9
|
+
in_progress: 'in_progress',
|
|
10
|
+
active: 'in_progress',
|
|
11
|
+
doing: 'in_progress',
|
|
12
|
+
completed: 'completed',
|
|
13
|
+
complete: 'completed',
|
|
14
|
+
done: 'completed',
|
|
15
|
+
};
|
|
16
|
+
function normalizeStatus(raw) {
|
|
17
|
+
const key = String(raw ?? '')
|
|
18
|
+
.trim()
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[-\s]+/g, '_');
|
|
21
|
+
return STATUS_ALIASES[key] ?? null;
|
|
22
|
+
}
|
|
23
|
+
export function isCompletedStatus(raw) {
|
|
24
|
+
return normalizeStatus(raw) === 'completed';
|
|
25
|
+
}
|
|
26
|
+
const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
|
|
27
|
+
export function extractTodosArg(args) {
|
|
28
|
+
if (!args || typeof args !== 'object')
|
|
29
|
+
return undefined;
|
|
30
|
+
const record = args;
|
|
31
|
+
if (record.todos !== undefined)
|
|
32
|
+
return record.todos;
|
|
33
|
+
for (const key of TODOS_ARG_ALIASES) {
|
|
34
|
+
if (record[key] !== undefined)
|
|
35
|
+
return record[key];
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
export function setTodoSession(sessionId) {
|
|
40
|
+
const next = String(sessionId ?? '').trim() || null;
|
|
41
|
+
if (activeSessionId !== next) {
|
|
42
|
+
items = [];
|
|
43
|
+
}
|
|
44
|
+
activeSessionId = next;
|
|
45
|
+
}
|
|
46
|
+
export function listTodos() {
|
|
47
|
+
return items.map((item) => ({ ...item }));
|
|
48
|
+
}
|
|
49
|
+
export function getTodoSnapshot() {
|
|
50
|
+
return {
|
|
51
|
+
items: listTodos(),
|
|
52
|
+
completedCount: items.filter((item) => item.status === 'completed').length,
|
|
53
|
+
totalCount: items.length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function clearTodos() {
|
|
57
|
+
items = [];
|
|
58
|
+
}
|
|
59
|
+
export function replaceTodos(raw) {
|
|
60
|
+
if (!Array.isArray(raw)) {
|
|
61
|
+
return { ok: false, error: 'todos must be an array of { text, status } items.' };
|
|
62
|
+
}
|
|
63
|
+
const normalizations = [];
|
|
64
|
+
if (raw.length > MAX_TODO_ITEMS) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const next = [];
|
|
71
|
+
let sawInProgress = false;
|
|
72
|
+
for (const [index, entry] of raw.entries()) {
|
|
73
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
74
|
+
return { ok: false, error: `todos[${index}] must be an object with text and status.` };
|
|
75
|
+
}
|
|
76
|
+
const text = String(entry.text ?? '')
|
|
77
|
+
.replace(/\s+/g, ' ')
|
|
78
|
+
.trim();
|
|
79
|
+
if (!text) {
|
|
80
|
+
return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
|
|
81
|
+
}
|
|
82
|
+
const status = normalizeStatus(entry.status);
|
|
83
|
+
if (!status) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let boundedText = text;
|
|
90
|
+
if (boundedText.length > MAX_TODO_TEXT_CHARS) {
|
|
91
|
+
boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
|
|
92
|
+
normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
|
|
93
|
+
}
|
|
94
|
+
let finalStatus = status;
|
|
95
|
+
if (status === 'in_progress') {
|
|
96
|
+
if (sawInProgress) {
|
|
97
|
+
finalStatus = 'pending';
|
|
98
|
+
normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
|
|
99
|
+
}
|
|
100
|
+
sawInProgress = true;
|
|
101
|
+
}
|
|
102
|
+
next.push({ text: boundedText, status: finalStatus });
|
|
103
|
+
}
|
|
104
|
+
items = next;
|
|
105
|
+
return { ok: true, snapshot: getTodoSnapshot(), normalizations };
|
|
106
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { drainBackgroundJobNotifications, getBackgroundJob, } from './background-jobs.js';
|
|
1
2
|
import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_RECORDS, operationFromSnapshots, readFileEditSnapshot, } from './edit-journal.js';
|
|
2
3
|
import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
|
|
3
4
|
import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
|
|
5
|
+
import { classifyProjectPath } from './patcher.js';
|
|
4
6
|
import { dispatchTool } from './tools/index.js';
|
|
7
|
+
import { syncIndexFromDisk } from './project-index.js';
|
|
8
|
+
import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
|
|
5
9
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
|
|
10
|
+
import { extractTodosArg } from './todo-list.js';
|
|
6
11
|
const EDIT_FILE_PATH_ARG_ALIASES = [
|
|
7
12
|
'filePath',
|
|
8
13
|
'file_path',
|
|
@@ -11,6 +16,7 @@ const EDIT_FILE_PATH_ARG_ALIASES = [
|
|
|
11
16
|
'file',
|
|
12
17
|
'filename',
|
|
13
18
|
];
|
|
19
|
+
const backgroundCommandTrackers = new Map();
|
|
14
20
|
function toolCallSummary(call) {
|
|
15
21
|
const args = call.args && typeof call.args === 'object' ? call.args : {};
|
|
16
22
|
if (call.name === 'run_command') {
|
|
@@ -19,6 +25,17 @@ function toolCallSummary(call) {
|
|
|
19
25
|
if (call.name === 'run_node_script') {
|
|
20
26
|
return String(args.script ?? '').trim().slice(0, 120);
|
|
21
27
|
}
|
|
28
|
+
if (call.name === 'shell_job_output' ||
|
|
29
|
+
call.name === 'shell_job_kill') {
|
|
30
|
+
return String(args.job_id ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
if (call.name === 'update_todos') {
|
|
33
|
+
const raw = extractTodosArg(args);
|
|
34
|
+
const todos = Array.isArray(raw) ? raw : [];
|
|
35
|
+
if (todos.length === 0)
|
|
36
|
+
return '';
|
|
37
|
+
return `${todos.length} item${todos.length === 1 ? '' : 's'}`;
|
|
38
|
+
}
|
|
22
39
|
const filePath = getEditToolFilePath(call);
|
|
23
40
|
if (filePath)
|
|
24
41
|
return filePath;
|
|
@@ -45,8 +62,125 @@ function getEditToolFilePath(call) {
|
|
|
45
62
|
}
|
|
46
63
|
return '';
|
|
47
64
|
}
|
|
65
|
+
function editToolWritesSeparateOutput(call) {
|
|
66
|
+
if (call.name !== 'replace_document_text')
|
|
67
|
+
return false;
|
|
68
|
+
const args = call.args && typeof call.args === 'object' ? call.args : {};
|
|
69
|
+
const output = args.outputPath ?? args.output_path;
|
|
70
|
+
return typeof output === 'string' && output.trim().length > 0;
|
|
71
|
+
}
|
|
72
|
+
async function collectTrackedCommandMutations({ session, projectIndex, result, tracker, toolName, toolCallId, turnId, }) {
|
|
73
|
+
const checkpoint = ensureActiveCheckpoint(session.clientState.safety, turnId);
|
|
74
|
+
const records = collectCommandMutations({
|
|
75
|
+
state: session.clientState.safety,
|
|
76
|
+
rootDir: session.rootDir,
|
|
77
|
+
tracker,
|
|
78
|
+
toolName,
|
|
79
|
+
toolCallId,
|
|
80
|
+
turnId,
|
|
81
|
+
checkpointId: checkpoint.id,
|
|
82
|
+
});
|
|
83
|
+
if (!records.length)
|
|
84
|
+
return;
|
|
85
|
+
invalidateShellDiagnosticsCache(session.rootDir);
|
|
86
|
+
rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
|
|
87
|
+
const priorSync = result.repoSync;
|
|
88
|
+
if (!(priorSync && (priorSync.added || priorSync.modified || priorSync.removed))) {
|
|
89
|
+
const mutationCounts = {
|
|
90
|
+
added: records.filter((record) => record.operation === 'create').length,
|
|
91
|
+
modified: records.filter((record) => record.operation === 'update').length,
|
|
92
|
+
removed: records.filter((record) => record.operation === 'delete').length,
|
|
93
|
+
indexedChunks: 0,
|
|
94
|
+
retrievalTokensUsed: 0,
|
|
95
|
+
};
|
|
96
|
+
if (priorSync?.error) {
|
|
97
|
+
result.repoSync = {
|
|
98
|
+
...mutationCounts,
|
|
99
|
+
indexSyncError: priorSync.error,
|
|
100
|
+
skipped: true,
|
|
101
|
+
reason: 'local index sync failed after command execution',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
const repoSync = await syncIndexFromDisk(projectIndex);
|
|
107
|
+
result.repoSync = {
|
|
108
|
+
...repoSync,
|
|
109
|
+
added: Math.max(repoSync.added, mutationCounts.added),
|
|
110
|
+
modified: Math.max(repoSync.modified, mutationCounts.modified),
|
|
111
|
+
removed: Math.max(repoSync.removed, mutationCounts.removed),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const indexSyncError = error instanceof Error ? error.message : String(error);
|
|
116
|
+
result.repoSync = { ...mutationCounts, indexSyncError };
|
|
117
|
+
session.onStatus(`Repository changes were recorded, but local index sync failed: ${indexSyncError}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
result.sessionEdits = records.map((record) => ({
|
|
122
|
+
id: record.id,
|
|
123
|
+
filePath: record.filePath,
|
|
124
|
+
operation: record.operation,
|
|
125
|
+
beforeHash: record.beforeHash,
|
|
126
|
+
afterHash: record.afterHash,
|
|
127
|
+
}));
|
|
128
|
+
result.diagnostics = runShellDiagnostics(session.rootDir);
|
|
129
|
+
}
|
|
130
|
+
export async function collectBackgroundJobUiKillMutations({ session, projectIndex, jobId, result, }) {
|
|
131
|
+
const normalizedJobId = String(jobId ?? result.snapshot?.id ?? '').trim();
|
|
132
|
+
if (!normalizedJobId || !result.ok)
|
|
133
|
+
return;
|
|
134
|
+
const tracked = backgroundCommandTrackers.get(normalizedJobId);
|
|
135
|
+
if (!tracked)
|
|
136
|
+
return;
|
|
137
|
+
const mutationResult = result;
|
|
138
|
+
await collectTrackedCommandMutations({
|
|
139
|
+
session,
|
|
140
|
+
projectIndex,
|
|
141
|
+
result: mutationResult,
|
|
142
|
+
tracker: tracked.tracker,
|
|
143
|
+
toolName: tracked.toolName,
|
|
144
|
+
toolCallId: tracked.toolCallId,
|
|
145
|
+
turnId: tracked.turnId,
|
|
146
|
+
});
|
|
147
|
+
if (result.snapshot?.status === 'running') {
|
|
148
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
backgroundCommandTrackers.delete(normalizedJobId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export async function collectBackgroundJobUiOutputMutations({ session, projectIndex, jobId, }) {
|
|
155
|
+
const normalizedJobId = String(jobId ?? '').trim();
|
|
156
|
+
if (!normalizedJobId)
|
|
157
|
+
return;
|
|
158
|
+
const tracked = backgroundCommandTrackers.get(normalizedJobId);
|
|
159
|
+
if (!tracked)
|
|
160
|
+
return;
|
|
161
|
+
const snapshot = getBackgroundJob(normalizedJobId, {
|
|
162
|
+
sessionId: session.sessionId,
|
|
163
|
+
});
|
|
164
|
+
if (!snapshot)
|
|
165
|
+
return;
|
|
166
|
+
await collectTrackedCommandMutations({
|
|
167
|
+
session,
|
|
168
|
+
projectIndex,
|
|
169
|
+
result: {},
|
|
170
|
+
tracker: tracked.tracker,
|
|
171
|
+
toolName: tracked.toolName,
|
|
172
|
+
toolCallId: tracked.toolCallId,
|
|
173
|
+
turnId: tracked.turnId,
|
|
174
|
+
});
|
|
175
|
+
if (snapshot.status === 'running') {
|
|
176
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
backgroundCommandTrackers.delete(normalizedJobId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
48
182
|
function recordAssistantEdit(session, call, result, before) {
|
|
49
|
-
if (!before || !isEditToolName(call.name))
|
|
183
|
+
if (!before || !isEditToolName(call.name) || result?.scratch === true)
|
|
50
184
|
return;
|
|
51
185
|
if (!result || typeof result !== 'object' || result.ok !== true) {
|
|
52
186
|
const filePath = getEditToolFilePath(call);
|
|
@@ -119,11 +253,20 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
119
253
|
session.onToolEvent?.({ call, result });
|
|
120
254
|
return result;
|
|
121
255
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
256
|
+
const rawEditFilePath = isEditToolName(call.name)
|
|
257
|
+
? getEditToolFilePath(call)
|
|
258
|
+
: '';
|
|
259
|
+
const filePathBeforeEdit = rawEditFilePath &&
|
|
260
|
+
PATH_REPAIRING_EDIT_TOOLS.has(call.name) &&
|
|
261
|
+
!editToolWritesSeparateOutput(call)
|
|
262
|
+
? repairFilePath(session.rootDir, rawEditFilePath)
|
|
263
|
+
: rawEditFilePath;
|
|
264
|
+
const tracksRepositoryEdit = filePathBeforeEdit &&
|
|
265
|
+
classifyProjectPath(session.rootDir, filePathBeforeEdit) === 'project';
|
|
266
|
+
if (tracksRepositoryEdit) {
|
|
124
267
|
rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
|
|
125
268
|
}
|
|
126
|
-
const beforeEditSnapshot =
|
|
269
|
+
const beforeEditSnapshot = tracksRepositoryEdit
|
|
127
270
|
? readFileEditSnapshot(session.rootDir, filePathBeforeEdit)
|
|
128
271
|
: null;
|
|
129
272
|
const commandTracker = call.name === 'run_command' || call.name === 'run_node_script'
|
|
@@ -131,6 +274,7 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
131
274
|
: null;
|
|
132
275
|
const context = {
|
|
133
276
|
rootDir: session.rootDir,
|
|
277
|
+
sessionId: session.sessionId,
|
|
134
278
|
projectIndex: toolContext.projectIndex,
|
|
135
279
|
autoYes: session.autoYes,
|
|
136
280
|
confirmCommand: session.confirmCommand,
|
|
@@ -152,28 +296,57 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
152
296
|
};
|
|
153
297
|
const result = await dispatchTool(context, call);
|
|
154
298
|
recordAssistantEdit(session, call, result, beforeEditSnapshot);
|
|
155
|
-
if (result &&
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
299
|
+
if (result && typeof result === 'object' && commandTracker) {
|
|
300
|
+
await collectTrackedCommandMutations({
|
|
301
|
+
session,
|
|
302
|
+
projectIndex: toolContext.projectIndex,
|
|
303
|
+
result,
|
|
160
304
|
tracker: commandTracker,
|
|
161
305
|
toolName: call.name,
|
|
162
306
|
toolCallId: call.id,
|
|
163
307
|
turnId: session.turnState.id,
|
|
164
|
-
checkpointId: checkpoint.id,
|
|
165
308
|
});
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
result.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
|
|
309
|
+
if (call.name === 'run_command' &&
|
|
310
|
+
result.backgrounded === true &&
|
|
311
|
+
result.status === 'running' &&
|
|
312
|
+
result.jobId) {
|
|
313
|
+
backgroundCommandTrackers.set(String(result.jobId), {
|
|
314
|
+
tracker: captureMutationBaseline(session.rootDir),
|
|
315
|
+
toolName: call.name,
|
|
316
|
+
toolCallId: call.id,
|
|
317
|
+
turnId: session.turnState.id,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (result &&
|
|
322
|
+
typeof result === 'object' &&
|
|
323
|
+
(call.name === 'shell_job_output' || call.name === 'shell_job_kill')) {
|
|
324
|
+
const jobId = String(result.jobId ?? call.args?.job_id ?? '').trim();
|
|
325
|
+
const tracked = backgroundCommandTrackers.get(jobId);
|
|
326
|
+
if (tracked) {
|
|
327
|
+
await collectTrackedCommandMutations({
|
|
328
|
+
session,
|
|
329
|
+
projectIndex: toolContext.projectIndex,
|
|
330
|
+
result,
|
|
331
|
+
tracker: tracked.tracker,
|
|
332
|
+
toolName: tracked.toolName,
|
|
333
|
+
toolCallId: tracked.toolCallId,
|
|
334
|
+
turnId: tracked.turnId,
|
|
335
|
+
});
|
|
336
|
+
if (result.status === 'running') {
|
|
337
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
backgroundCommandTrackers.delete(jobId);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (result && typeof result === 'object') {
|
|
345
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
346
|
+
sessionId: session.sessionId,
|
|
347
|
+
});
|
|
348
|
+
if (backgroundJobUpdate) {
|
|
349
|
+
result.backgroundJobUpdate = backgroundJobUpdate;
|
|
177
350
|
}
|
|
178
351
|
}
|
|
179
352
|
session.onToolEvent?.({ call, result });
|