@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import chalk from '
|
|
2
|
-
import { deleteProjectFile } from '../patcher.js';
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
|
+
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';
|
|
@@ -9,10 +9,27 @@ export async function deleteFile(context, args) {
|
|
|
9
9
|
if (!filePath) {
|
|
10
10
|
return { ok: false, error: 'filePath is required' };
|
|
11
11
|
}
|
|
12
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
13
|
+
if (pathKind === 'outside') {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
filePath,
|
|
17
|
+
error: `Refusing to delete outside the project root: ${filePath}. Deletable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
18
|
+
failureCategory: 'invalid_argument',
|
|
19
|
+
failureDetails: {
|
|
20
|
+
category: 'invalid_argument',
|
|
21
|
+
tool: 'delete_file',
|
|
22
|
+
action: 'Delete files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR).',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const scratchPath = pathKind === 'scratch';
|
|
12
27
|
const result = deleteProjectFile(rootDir, filePath);
|
|
13
28
|
if (result.deleted) {
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
if (!scratchPath) {
|
|
30
|
+
await removeIndexFile(projectIndex, filePath);
|
|
31
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
32
|
+
}
|
|
16
33
|
if (!isTuiMode())
|
|
17
34
|
console.log(chalk.red(` 🗑️ Deleted: ${filePath}`));
|
|
18
35
|
}
|
|
@@ -21,7 +38,8 @@ export async function deleteFile(context, args) {
|
|
|
21
38
|
filePath,
|
|
22
39
|
changed: result.deleted,
|
|
23
40
|
deleted: result.deleted,
|
|
41
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
24
42
|
content: result.content,
|
|
25
|
-
diagnostics: result.deleted ? runShellDiagnostics(rootDir) : undefined,
|
|
43
|
+
diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
|
|
26
44
|
};
|
|
27
45
|
}
|
package/dist/src/tools/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { shellJobOutput } from './shell-job-output.js';
|
|
1
2
|
import { deleteFile } from './delete-file.js';
|
|
2
3
|
import { findSymbol } from './find-symbol.js';
|
|
3
4
|
import { getDiagnostics } from './get-diagnostics.js';
|
|
@@ -17,8 +18,10 @@ import { runNodeScript } from './run-node-script.js';
|
|
|
17
18
|
import { restoreFilesToCheckpoint, restoreToCheckpoint, } from './restore-checkpoint.js';
|
|
18
19
|
import { searchCode } from './search-code.js';
|
|
19
20
|
import { getSignatureHelp } from './signature-help.js';
|
|
21
|
+
import { shellJobKill } from './shell-job-kill.js';
|
|
20
22
|
import { strReplace } from './str-replace.js';
|
|
21
23
|
import { undoEdit } from './undo-edit.js';
|
|
24
|
+
import { updateTodos } from './update-todos.js';
|
|
22
25
|
import { writeFile } from './write-file.js';
|
|
23
26
|
export const TOOL_MAP = {
|
|
24
27
|
search_code: (context, args) => searchCode(context.projectIndex, args),
|
|
@@ -44,6 +47,9 @@ export const TOOL_MAP = {
|
|
|
44
47
|
undo_edit: undoEdit,
|
|
45
48
|
run_command: runShellCommand,
|
|
46
49
|
run_node_script: runNodeScript,
|
|
50
|
+
shell_job_output: shellJobOutput,
|
|
51
|
+
shell_job_kill: shellJobKill,
|
|
52
|
+
update_todos: updateTodos,
|
|
47
53
|
};
|
|
48
54
|
function invalidToolCall(error) {
|
|
49
55
|
return {
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
-
import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
4
|
+
import { applyUnifiedPatch, classifyProjectPath, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
5
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
6
|
+
import { repairFilePath } from './path-suggest.js';
|
|
6
7
|
import { isTuiMode } from '../runtime-mode.js';
|
|
7
8
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
8
9
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
10
|
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
10
11
|
export async function patchFile(context, args) {
|
|
11
12
|
const { rootDir, projectIndex, autoYes, confirmPatch } = context;
|
|
12
|
-
const filePath = String(args.filePath ?? '').trim();
|
|
13
|
+
const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
|
|
13
14
|
let patch = typeof args.patch === 'string' ? args.patch : '';
|
|
14
15
|
if (!filePath) {
|
|
15
16
|
return { ok: false, error: 'filePath is required' };
|
|
@@ -28,6 +29,21 @@ export async function patchFile(context, args) {
|
|
|
28
29
|
failureCategory: 'invalid_argument',
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
33
|
+
if (pathKind === 'outside') {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
filePath,
|
|
37
|
+
error: `Refusing to edit outside the project root: ${filePath}. Editable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
38
|
+
failureCategory: 'invalid_argument',
|
|
39
|
+
failureDetails: {
|
|
40
|
+
category: 'invalid_argument',
|
|
41
|
+
tool: 'patch_file',
|
|
42
|
+
action: 'Edit files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const scratchPath = pathKind === 'scratch';
|
|
31
47
|
let originalContent;
|
|
32
48
|
try {
|
|
33
49
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -67,20 +83,29 @@ export async function patchFile(context, args) {
|
|
|
67
83
|
ok: false,
|
|
68
84
|
skipped: true,
|
|
69
85
|
filePath,
|
|
70
|
-
|
|
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.',
|
|
71
93
|
};
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
|
|
75
97
|
let indexedChunks = 0;
|
|
76
98
|
let retrievalTokensUsed = 0;
|
|
77
|
-
if (changed) {
|
|
99
|
+
if (changed && !scratchPath) {
|
|
78
100
|
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
79
101
|
indexedChunks = indexResult.indexedChunks;
|
|
80
102
|
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
81
103
|
}
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
let diagnostics;
|
|
105
|
+
if (!scratchPath) {
|
|
106
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
107
|
+
diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
108
|
+
}
|
|
84
109
|
const originalLines = originalContent.split('\n').length;
|
|
85
110
|
const patchedLines = patchedContent.split('\n').length;
|
|
86
111
|
if (!isTuiMode()) {
|
|
@@ -93,6 +118,7 @@ export async function patchFile(context, args) {
|
|
|
93
118
|
filePath,
|
|
94
119
|
changed,
|
|
95
120
|
operation: 'patch',
|
|
121
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
96
122
|
indexedChunks,
|
|
97
123
|
retrievalTokensUsed,
|
|
98
124
|
bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isSensitiveProjectPath } from '../artifact-policy.js';
|
|
4
|
-
// "File not found" recovery hint: when a model mistypes a filename (most
|
|
5
|
-
// often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
|
|
6
|
-
// suggest the closest real file from the same directory.
|
|
3
|
+
import { isSensitiveProjectPath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
|
|
7
4
|
function foldName(name) {
|
|
8
5
|
return name
|
|
9
6
|
.normalize('NFC')
|
|
@@ -46,9 +43,6 @@ export function suggestClosestPath(rootDir, missingPath) {
|
|
|
46
43
|
let best = null;
|
|
47
44
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
48
45
|
for (const candidate of candidates) {
|
|
49
|
-
// Never suggest a file the caller would refuse to read/write directly:
|
|
50
|
-
// probing a near-miss like `.enx` or `credential.docx` must not leak the
|
|
51
|
-
// existence of `.env`/credentials through the recovery hint.
|
|
52
46
|
const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
|
|
53
47
|
if (isSensitiveProjectPath(candidateRelative))
|
|
54
48
|
continue;
|
|
@@ -64,3 +58,82 @@ export function suggestClosestPath(rootDir, missingPath) {
|
|
|
64
58
|
const relative = path.relative(rootDir, suggested);
|
|
65
59
|
return relative && !relative.startsWith('..') ? relative : suggested;
|
|
66
60
|
}
|
|
61
|
+
function foldPunctuation(name) {
|
|
62
|
+
return name
|
|
63
|
+
.normalize('NFC')
|
|
64
|
+
.replace(/[‘’ʼ]/g, "'")
|
|
65
|
+
.replace(/[“”]/g, '"')
|
|
66
|
+
.replace(/ /g, ' ');
|
|
67
|
+
}
|
|
68
|
+
function stripSurroundingQuotes(p) {
|
|
69
|
+
if (p.length >= 2) {
|
|
70
|
+
const first = p[0];
|
|
71
|
+
const last = p[p.length - 1];
|
|
72
|
+
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
73
|
+
return p.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
function collapseDoubledBackslashes(p) {
|
|
79
|
+
return p.replace(/\\\\/g, '\\');
|
|
80
|
+
}
|
|
81
|
+
function resolveAgainst(rootDir, p) {
|
|
82
|
+
return path.isAbsolute(p) ? p : path.resolve(rootDir, p);
|
|
83
|
+
}
|
|
84
|
+
function existsAgainst(rootDir, p) {
|
|
85
|
+
try {
|
|
86
|
+
return existsSync(resolveAgainst(rootDir, p));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function isProtectedRepairTarget(rootDir, candidate) {
|
|
93
|
+
const rel = path.relative(rootDir, resolveAgainst(rootDir, candidate));
|
|
94
|
+
const projectPath = rel && !rel.startsWith('..') ? rel : candidate;
|
|
95
|
+
return (isSensitiveProjectPath(projectPath) ||
|
|
96
|
+
(rel !== '' && !rel.startsWith('..') && shouldIgnoreArtifactPath(rel)));
|
|
97
|
+
}
|
|
98
|
+
export const PATH_REPAIRING_EDIT_TOOLS = new Set([
|
|
99
|
+
'str_replace',
|
|
100
|
+
'patch_file',
|
|
101
|
+
'replace_document_text',
|
|
102
|
+
]);
|
|
103
|
+
export function repairFilePath(rootDir, raw) {
|
|
104
|
+
if (!raw || existsAgainst(rootDir, raw))
|
|
105
|
+
return raw;
|
|
106
|
+
const dequoted = stripSurroundingQuotes(raw);
|
|
107
|
+
for (const candidate of [
|
|
108
|
+
dequoted,
|
|
109
|
+
collapseDoubledBackslashes(raw),
|
|
110
|
+
collapseDoubledBackslashes(dequoted),
|
|
111
|
+
]) {
|
|
112
|
+
if (candidate !== raw &&
|
|
113
|
+
existsAgainst(rootDir, candidate) &&
|
|
114
|
+
!isProtectedRepairTarget(rootDir, candidate)) {
|
|
115
|
+
return candidate;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const probe = resolveAgainst(rootDir, dequoted);
|
|
119
|
+
const directory = path.dirname(probe);
|
|
120
|
+
const wanted = foldPunctuation(path.basename(probe));
|
|
121
|
+
if (!wanted)
|
|
122
|
+
return raw;
|
|
123
|
+
let entries;
|
|
124
|
+
try {
|
|
125
|
+
entries = readdirSync(directory);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return raw;
|
|
129
|
+
}
|
|
130
|
+
const matches = entries.filter((entry) => foldPunctuation(entry) === wanted);
|
|
131
|
+
if (matches.length !== 1)
|
|
132
|
+
return raw;
|
|
133
|
+
const matchedAbs = path.join(directory, matches[0]);
|
|
134
|
+
const matchedRel = path.relative(rootDir, matchedAbs);
|
|
135
|
+
const matched = path.isAbsolute(dequoted) ? matchedAbs : matchedRel || matchedAbs;
|
|
136
|
+
if (isProtectedRepairTarget(rootDir, matched))
|
|
137
|
+
return raw;
|
|
138
|
+
return matched;
|
|
139
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
4
|
-
import { suggestClosestPath } from './path-suggest.js';
|
|
4
|
+
import { repairFilePath, suggestClosestPath } from './path-suggest.js';
|
|
5
5
|
import { readCliAuthConfig } from '../api/auth.js';
|
|
6
6
|
export function normalizeDocumentText(raw) {
|
|
7
7
|
const text = String(raw ?? '').replace(/\r\n?/g, '\n');
|
|
@@ -71,7 +71,7 @@ async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
|
71
71
|
return data;
|
|
72
72
|
}
|
|
73
73
|
export async function readDocument(rootDir, args, env) {
|
|
74
|
-
const raw = String(args.filePath ?? '').trim();
|
|
74
|
+
const raw = repairFilePath(rootDir, String(args.filePath ?? '').trim());
|
|
75
75
|
if (!raw) {
|
|
76
76
|
return { ok: false, error: 'filePath is required' };
|
|
77
77
|
}
|
|
@@ -3,6 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import { normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
|
|
4
4
|
import { buildSecretFilePreview, isDotenvLikePath, looksLikeEditableDotenv, shouldUseSecretFilePreview, } from '../secret-preview.js';
|
|
5
5
|
import { readProjectFile } from '../patcher.js';
|
|
6
|
+
import { isWithinSessionScratchDir } from '../scratch-dir.js';
|
|
7
|
+
import { repairFilePath } from './path-suggest.js';
|
|
6
8
|
import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
|
|
7
9
|
import { readFileRange, truncate } from '../utils.js';
|
|
8
10
|
const MAX_FILE_READ_CHARS = 12000;
|
|
@@ -10,11 +12,12 @@ const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
|
10
12
|
export async function readFile(context, args) {
|
|
11
13
|
const rootDir = typeof context === 'string' ? context : context.rootDir;
|
|
12
14
|
const safety = typeof context === 'string' ? undefined : context.safety;
|
|
13
|
-
const filePath = String(args.filePath ?? '').trim();
|
|
15
|
+
const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
|
|
14
16
|
if (!filePath) {
|
|
15
17
|
return { ok: false, error: 'filePath is required' };
|
|
16
18
|
}
|
|
17
19
|
const projectPath = normalizeProjectRelativePath(rootDir, filePath);
|
|
20
|
+
const scratchPath = path.isAbsolute(filePath) && isWithinSessionScratchDir(filePath);
|
|
18
21
|
if (!projectPath && !path.isAbsolute(filePath)) {
|
|
19
22
|
return {
|
|
20
23
|
ok: false,
|
|
@@ -36,18 +39,28 @@ export async function readFile(context, args) {
|
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
let content;
|
|
39
|
-
if (projectPath) {
|
|
42
|
+
if (projectPath || scratchPath) {
|
|
40
43
|
try {
|
|
41
44
|
content = readProjectFile(rootDir, filePath);
|
|
42
45
|
}
|
|
43
46
|
catch (err) {
|
|
44
|
-
|
|
47
|
+
const message = String(err?.message ?? err);
|
|
48
|
+
const notFound = /^File does not exist:/.test(message);
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: message,
|
|
52
|
+
...(notFound ? { failureCategory: 'not_found' } : {}),
|
|
53
|
+
};
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
else {
|
|
48
57
|
const absPath = path.resolve(filePath);
|
|
49
58
|
if (!existsSync(absPath)) {
|
|
50
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `File does not exist: ${filePath}`,
|
|
62
|
+
failureCategory: 'not_found',
|
|
63
|
+
};
|
|
51
64
|
}
|
|
52
65
|
try {
|
|
53
66
|
content = readFileSync(absPath, 'utf-8');
|
|
@@ -57,10 +70,6 @@ export async function readFile(context, args) {
|
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
const previewPath = projectPath ?? filePath;
|
|
60
|
-
// A clean dotenv file is shown with keys visible and values tokenized so the
|
|
61
|
-
// agent can still edit it (str_replace/write_file round-trip the tokens) and
|
|
62
|
-
// read coverage is recorded. Any other secret file — PEM, JSON credentials,
|
|
63
|
-
// or a dotenv with a stray non-assignment line — keeps the opaque blackout.
|
|
64
73
|
const editableDotenv = Boolean(projectPath) &&
|
|
65
74
|
Boolean(safety) &&
|
|
66
75
|
isDotenvLikePath(previewPath) &&
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
5
5
|
import { readCliAuthConfig } from '../api/auth.js';
|
|
6
6
|
import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
|
|
7
|
-
import { suggestClosestPath } from './path-suggest.js';
|
|
7
|
+
import { repairFilePath, suggestClosestPath } from './path-suggest.js';
|
|
8
8
|
import { isTuiMode } from '../runtime-mode.js';
|
|
9
9
|
function normalizeReplacements(value) {
|
|
10
10
|
if (!Array.isArray(value))
|
|
@@ -76,7 +76,7 @@ async function replaceDocumentTextOnServer(config, fileName, fileData, replaceme
|
|
|
76
76
|
return data;
|
|
77
77
|
}
|
|
78
78
|
export async function replaceDocumentText(context, args) {
|
|
79
|
-
const sourceRaw = String(args.filePath ?? args.file_path ?? '').trim();
|
|
79
|
+
const sourceRaw = repairFilePath(context.rootDir, String(args.filePath ?? args.file_path ?? '').trim());
|
|
80
80
|
if (!sourceRaw) {
|
|
81
81
|
return { ok: false, error: 'filePath is required' };
|
|
82
82
|
}
|
|
@@ -149,9 +149,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
149
149
|
failureCategory: serverResult.failureCategory ?? 'external_service',
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
|
-
// Validate-only: report per-replacement match info without touching the file.
|
|
153
|
-
// changed:false marks it non-mutating so the agent loop does not count a
|
|
154
|
-
// dry-run as an applied edit.
|
|
155
152
|
if (validateOnly) {
|
|
156
153
|
return {
|
|
157
154
|
ok: true,
|
|
@@ -162,8 +159,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
162
159
|
results: serverResult.results,
|
|
163
160
|
};
|
|
164
161
|
}
|
|
165
|
-
// No replacement matched: nothing was written. Surface per-item reasons so
|
|
166
|
-
// the model can correct and resend only the failing entries.
|
|
167
162
|
const replacementCount = Number(serverResult.replacementCount ?? 0);
|
|
168
163
|
if (replacementCount === 0) {
|
|
169
164
|
const failures = Array.isArray(serverResult.replacements)
|
|
@@ -193,7 +188,13 @@ export async function replaceDocumentText(context, args) {
|
|
|
193
188
|
ok: false,
|
|
194
189
|
skipped: true,
|
|
195
190
|
filePath: targetPath,
|
|
196
|
-
|
|
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.',
|
|
197
198
|
};
|
|
198
199
|
}
|
|
199
200
|
}
|
|
@@ -220,9 +221,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
220
221
|
failedCount: serverResult.failedCount,
|
|
221
222
|
replacements: serverResult.replacements,
|
|
222
223
|
bytesWritten: nextData.length,
|
|
223
|
-
// A partial batch still wrote the matched entries (changed:true above), but
|
|
224
|
-
// the loop must reflect and repair the missed entries — needsRepair forces
|
|
225
|
-
// that without losing credit for the applied edits.
|
|
226
224
|
...(failedCount > 0
|
|
227
225
|
? {
|
|
228
226
|
needsRepair: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
2
|
import { isTuiMode } from '../runtime-mode.js';
|
|
3
3
|
import { createPromptCheckpoint, restoreCheckpointFiles, } from '../session-safety.js';
|
|
4
4
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
|
+
import { startBackgroundJob } from '../background-jobs.js';
|
|
2
3
|
import { getBlockedCommandReason, runCommand, } from '../executor.js';
|
|
3
4
|
import { syncIndexFromDisk } from '../project-index.js';
|
|
4
5
|
import { isTuiMode } from '../runtime-mode.js';
|
|
@@ -12,9 +13,10 @@ export async function runShellCommand(context, args) {
|
|
|
12
13
|
if (!command) {
|
|
13
14
|
return { ok: false, error: 'command is required' };
|
|
14
15
|
}
|
|
16
|
+
const runInBackground = args.background === true;
|
|
15
17
|
const hasTimeout = typeof args.timeout_ms === 'number' && args.timeout_ms > 0;
|
|
16
18
|
const repoHint = buildNestedGitHint(rootDir, command);
|
|
17
|
-
const blockedReason = getBlockedCommandReason(command, hasTimeout, rootDir);
|
|
19
|
+
const blockedReason = getBlockedCommandReason(command, hasTimeout || runInBackground, rootDir);
|
|
18
20
|
if (blockedReason) {
|
|
19
21
|
const error = blockedReason;
|
|
20
22
|
if (!isTuiMode())
|
|
@@ -37,7 +39,9 @@ export async function runShellCommand(context, args) {
|
|
|
37
39
|
error: 'confirmCommand is required when autoYes is false',
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
|
-
const approved = await confirmCommand(
|
|
42
|
+
const approved = await confirmCommand(runInBackground
|
|
43
|
+
? `${command}\n\nRuns as a managed background job until it exits or is killed.`
|
|
44
|
+
: command);
|
|
41
45
|
if (!approved) {
|
|
42
46
|
if (!isTuiMode())
|
|
43
47
|
console.log(chalk.dim(` ⏭ Skipped: ${command}`));
|
|
@@ -45,50 +49,131 @@ export async function runShellCommand(context, args) {
|
|
|
45
49
|
ok: false,
|
|
46
50
|
skipped: true,
|
|
47
51
|
command,
|
|
48
|
-
|
|
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.',
|
|
49
59
|
};
|
|
50
60
|
}
|
|
51
61
|
}
|
|
62
|
+
if (runInBackground) {
|
|
63
|
+
return runBackgroundCommand(context, command, args.timeout_ms, repoHint);
|
|
64
|
+
}
|
|
52
65
|
const result = await runCommand(command, rootDir, {
|
|
53
66
|
requestSudoPassword,
|
|
54
67
|
timeout: typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined,
|
|
55
68
|
});
|
|
56
|
-
const repoSync = projectIndex
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
70
|
+
invalidateShellDiagnosticsCache(rootDir);
|
|
71
|
+
const diagnostics = buildDeferredShellDiagnostics('run_command');
|
|
72
|
+
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
73
|
+
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
74
|
+
}
|
|
75
|
+
const output = typeof result.output === 'string'
|
|
76
|
+
? boundCommandOutput(redactConnectionStringCredentials(result.output))
|
|
77
|
+
: result.output;
|
|
78
|
+
return {
|
|
79
|
+
ok: result.exitCode === 0,
|
|
80
|
+
command,
|
|
81
|
+
exitCode: result.exitCode,
|
|
82
|
+
timedOut: result.timedOut,
|
|
83
|
+
output,
|
|
84
|
+
repoSync,
|
|
85
|
+
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
86
|
+
diagnostics,
|
|
87
|
+
repoHint,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function boundCommandOutput(output) {
|
|
91
|
+
if (output.length <= MAX_OUTPUT_CHARS)
|
|
92
|
+
return output;
|
|
93
|
+
const headSize = Math.floor(MAX_OUTPUT_CHARS * 0.2);
|
|
94
|
+
const tailSize = MAX_OUTPUT_CHARS - headSize;
|
|
95
|
+
return (output.slice(0, headSize) +
|
|
96
|
+
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
97
|
+
output.slice(-tailSize));
|
|
98
|
+
}
|
|
99
|
+
async function syncRepoIndex(projectIndex, onStatus) {
|
|
100
|
+
if (!projectIndex.initialized) {
|
|
101
|
+
return {
|
|
59
102
|
added: 0,
|
|
60
103
|
modified: 0,
|
|
61
104
|
removed: 0,
|
|
62
105
|
indexedChunks: 0,
|
|
63
106
|
retrievalTokensUsed: 0,
|
|
64
107
|
};
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await syncIndexFromDisk(projectIndex);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
onStatus(`Command completed, but local index sync failed: ${message}`);
|
|
115
|
+
return {
|
|
116
|
+
added: 0,
|
|
117
|
+
modified: 0,
|
|
118
|
+
removed: 0,
|
|
119
|
+
indexedChunks: 0,
|
|
120
|
+
retrievalTokensUsed: 0,
|
|
121
|
+
skipped: true,
|
|
122
|
+
reason: 'local index sync failed after command execution',
|
|
123
|
+
error: message,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
128
|
+
const { rootDir, projectIndex, onStatus } = context;
|
|
129
|
+
const started = await startBackgroundJob(command, rootDir, {
|
|
130
|
+
startupWaitMs: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined,
|
|
131
|
+
sessionId: context.sessionId,
|
|
132
|
+
});
|
|
133
|
+
if (!started.ok || !started.snapshot) {
|
|
134
|
+
if (!isTuiMode())
|
|
135
|
+
console.log(chalk.red(`\n ✖ ${started.error}`));
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
blocked: true,
|
|
139
|
+
command,
|
|
140
|
+
error: started.error ?? 'Background job failed to start.',
|
|
141
|
+
repoHint,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const snapshot = started.snapshot;
|
|
145
|
+
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
65
146
|
invalidateShellDiagnosticsCache(rootDir);
|
|
66
|
-
const diagnostics = buildDeferredShellDiagnostics('run_command');
|
|
67
147
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
68
148
|
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
69
149
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
output
|
|
80
|
-
|
|
81
|
-
|
|
150
|
+
const output = boundCommandOutput(redactConnectionStringCredentials(started.startupOutput ?? '').trim());
|
|
151
|
+
if (snapshot.status === 'running') {
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
backgrounded: true,
|
|
155
|
+
command,
|
|
156
|
+
jobId: snapshot.id,
|
|
157
|
+
status: 'running',
|
|
158
|
+
pid: snapshot.pid,
|
|
159
|
+
output,
|
|
160
|
+
note: `Background job ${snapshot.id} is running. Use shell_job_output to poll status and new output, and shell_job_kill to stop it.`,
|
|
161
|
+
repoSync,
|
|
162
|
+
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
163
|
+
repoHint,
|
|
164
|
+
};
|
|
82
165
|
}
|
|
83
166
|
return {
|
|
84
|
-
ok:
|
|
167
|
+
ok: snapshot.exitCode === 0,
|
|
168
|
+
backgrounded: true,
|
|
85
169
|
command,
|
|
86
|
-
|
|
87
|
-
|
|
170
|
+
jobId: snapshot.id,
|
|
171
|
+
status: snapshot.status,
|
|
172
|
+
exitCode: snapshot.exitCode,
|
|
88
173
|
output,
|
|
174
|
+
note: `Background job ${snapshot.id} finished during the startup window.`,
|
|
89
175
|
repoSync,
|
|
90
176
|
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
91
|
-
diagnostics,
|
|
92
177
|
repoHint,
|
|
93
178
|
};
|
|
94
179
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from '../colors.js';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
3
|
import { syncIndexFromDisk } from '../project-index.js';
|
|
4
4
|
import { isTuiMode } from '../runtime-mode.js';
|
|
5
5
|
import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
|
|
6
|
+
import { ensureSessionScratchDir } from '../scratch-dir.js';
|
|
6
7
|
const DEFAULT_TIMEOUT = 5 * 60 * 1000;
|
|
7
8
|
const MAX_OUTPUT_CHARS = 4000;
|
|
8
9
|
const MAX_CAPTURE_CHARS = 1024 * 1024;
|
|
@@ -99,6 +100,7 @@ function executeNodeScript(rootDir, script, timeout) {
|
|
|
99
100
|
npm_config_progress: 'false',
|
|
100
101
|
npm_config_fund: 'false',
|
|
101
102
|
npm_config_audit: 'false',
|
|
103
|
+
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
102
104
|
},
|
|
103
105
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
106
|
});
|
|
@@ -173,7 +175,13 @@ export async function runNodeScript(context, args) {
|
|
|
173
175
|
ok: false,
|
|
174
176
|
skipped: true,
|
|
175
177
|
command: COMMAND_LABEL,
|
|
176
|
-
|
|
178
|
+
failureCategory: 'user_declined',
|
|
179
|
+
failureDetails: {
|
|
180
|
+
category: 'user_declined',
|
|
181
|
+
tool: 'run_node_script',
|
|
182
|
+
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.',
|
|
183
|
+
},
|
|
184
|
+
error: 'The real user rejected this proposed script. Nothing was executed; this was not a tool failure or an automated system skip.',
|
|
177
185
|
};
|
|
178
186
|
}
|
|
179
187
|
}
|
|
@@ -184,13 +192,27 @@ export async function runNodeScript(context, args) {
|
|
|
184
192
|
const afterGitStatus = readGitStatusSignature(rootDir);
|
|
185
193
|
const gitStatusCleanBeforeAndAfter = beforeGitStatus === '' && afterGitStatus === '';
|
|
186
194
|
const shouldSync = context.projectIndex.initialized && !gitStatusCleanBeforeAndAfter;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
195
|
+
let repoSync;
|
|
196
|
+
if (shouldSync) {
|
|
197
|
+
try {
|
|
198
|
+
repoSync = await syncIndexFromDisk(context.projectIndex);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
context.onStatus(`Node script completed, but local index sync failed: ${message}`);
|
|
203
|
+
repoSync = {
|
|
204
|
+
...emptyRepoSync('local index sync failed after Node script execution'),
|
|
205
|
+
error: message,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
repoSync = emptyRepoSync(!context.projectIndex.initialized
|
|
190
211
|
? 'project index not initialized'
|
|
191
212
|
: gitStatusCleanBeforeAndAfter
|
|
192
213
|
? 'git status clean before and after'
|
|
193
214
|
: 'sync not needed');
|
|
215
|
+
}
|
|
194
216
|
invalidateShellDiagnosticsCache(rootDir);
|
|
195
217
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
196
218
|
context.onStatus(`Synced repo state after Node script (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|