@thegitai/cli 1.0.0-beta.15 → 1.0.0-beta.17
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/dist/bin/ai.js +92 -11
- package/dist/src/agent-mode.js +6 -0
- package/dist/src/api/chat.js +46 -9
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/image-path-extractor.js +55 -4
- package/dist/src/executor.js +27 -7
- package/dist/src/help-text.js +10 -0
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/tool-executor.js +132 -17
- package/dist/src/tools/index.js +4 -0
- package/dist/src/tools/run-command.js +81 -13
- package/dist/src/tools/run-node-script.js +2 -0
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/ui/repl.js +239 -3
- package/dist/src/ui/tui/build-frame.js +92 -5
- package/dist/src/ui/tui/shell-input.js +31 -0
- package/package.json +5 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ensureSessionScratchDir } from './scratch-dir.js';
|
|
4
5
|
const PACKAGE_MANAGER_CANDIDATES = [
|
|
5
6
|
'apt',
|
|
6
7
|
'apt-get',
|
|
@@ -123,5 +124,6 @@ export function collectClientEnvironment(options = {}) {
|
|
|
123
124
|
shell: detectShell(platform, env),
|
|
124
125
|
...linuxDistro,
|
|
125
126
|
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
127
|
+
scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
|
|
126
128
|
};
|
|
127
129
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { loadImageFromFile } from './clipboard.js';
|
|
@@ -9,12 +9,51 @@ const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
|
|
|
9
9
|
`|'([^']*\\.${EXT})'` +
|
|
10
10
|
`|file://(\\S*\\.${EXT})` +
|
|
11
11
|
`|(${BARE_PATH})`, 'gi');
|
|
12
|
+
const EXTENSION_TOKEN = new RegExp(`^(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+$`);
|
|
13
|
+
const MAX_EXTENSION_TOKENS = 8;
|
|
14
|
+
function isFile(p) {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function extendBareMatchAcrossSpaces(input, matchStart, raw, cwd) {
|
|
23
|
+
const baseInner = raw.replace(/\\ /g, ' ');
|
|
24
|
+
const baseExists = isFile(path.isAbsolute(baseInner) ? baseInner : path.resolve(cwd, baseInner));
|
|
25
|
+
let best = null;
|
|
26
|
+
let candidate = raw;
|
|
27
|
+
let start = matchStart;
|
|
28
|
+
let addedSeparator = false;
|
|
29
|
+
for (let hops = 0; hops < MAX_EXTENSION_TOKENS; hops++) {
|
|
30
|
+
if (start < 2 || input[start - 1] !== ' ' || /\s/.test(input[start - 2]))
|
|
31
|
+
break;
|
|
32
|
+
let i = start - 2;
|
|
33
|
+
while (i >= 0 && !/\s/.test(input[i]))
|
|
34
|
+
i--;
|
|
35
|
+
const tokenStart = i + 1;
|
|
36
|
+
const token = input.slice(tokenStart, start - 1);
|
|
37
|
+
if (!EXTENSION_TOKEN.test(token))
|
|
38
|
+
break;
|
|
39
|
+
candidate = `${token} ${candidate}`;
|
|
40
|
+
start = tokenStart;
|
|
41
|
+
addedSeparator = addedSeparator || /[\\/]/.test(token);
|
|
42
|
+
if (baseExists && !addedSeparator)
|
|
43
|
+
continue;
|
|
44
|
+
const inner = candidate.replace(/\\ /g, ' ');
|
|
45
|
+
const resolved = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
46
|
+
if (isFile(resolved))
|
|
47
|
+
best = candidate;
|
|
48
|
+
}
|
|
49
|
+
return best ?? raw;
|
|
50
|
+
}
|
|
12
51
|
function detectImagePaths(input, cwd) {
|
|
13
52
|
const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
|
|
14
|
-
const
|
|
53
|
+
const detected = [];
|
|
15
54
|
let match;
|
|
16
55
|
while ((match = regex.exec(input)) !== null) {
|
|
17
|
-
|
|
56
|
+
let raw = match[0];
|
|
18
57
|
if (match[4] != null && raw.includes('://'))
|
|
19
58
|
continue;
|
|
20
59
|
let inner;
|
|
@@ -46,9 +85,21 @@ function detectImagePaths(input, cwd) {
|
|
|
46
85
|
}
|
|
47
86
|
}
|
|
48
87
|
else {
|
|
49
|
-
|
|
88
|
+
raw = extendBareMatchAcrossSpaces(input, match.index, raw, cwd);
|
|
89
|
+
inner = raw.replace(/\\ /g, ' ');
|
|
50
90
|
}
|
|
51
91
|
const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
92
|
+
const start = match.index - (raw.length - match[0].length);
|
|
93
|
+
while (detected.length > 0) {
|
|
94
|
+
const prev = detected[detected.length - 1];
|
|
95
|
+
if (prev.start + prev.raw.length <= start)
|
|
96
|
+
break;
|
|
97
|
+
detected.pop();
|
|
98
|
+
}
|
|
99
|
+
detected.push({ resolvedPath, raw, start });
|
|
100
|
+
}
|
|
101
|
+
const rawsByPath = new Map();
|
|
102
|
+
for (const { resolvedPath, raw } of detected) {
|
|
52
103
|
const existing = rawsByPath.get(resolvedPath);
|
|
53
104
|
if (existing) {
|
|
54
105
|
existing.push(raw);
|
package/dist/src/executor.js
CHANGED
|
@@ -6,6 +6,7 @@ import os from 'os';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
|
|
8
8
|
import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
|
|
9
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
|
|
9
10
|
const requireFromHere = createRequire(import.meta.url);
|
|
10
11
|
let nodePtyCache;
|
|
11
12
|
function loadNodePty() {
|
|
@@ -217,6 +218,7 @@ function isExistingDirectory(absPath) {
|
|
|
217
218
|
return false;
|
|
218
219
|
}
|
|
219
220
|
}
|
|
221
|
+
const OS_TEMP_BLOCK_MARKER = 'os-temp:';
|
|
220
222
|
function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
221
223
|
const token = normalizeToken(rawToken);
|
|
222
224
|
if (!token || !path.isAbsolute(token))
|
|
@@ -224,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
224
226
|
const resolved = path.resolve(token);
|
|
225
227
|
if (!isInsideOsTemp(resolved))
|
|
226
228
|
return null;
|
|
229
|
+
if (isInsideTheGitAiScratch(resolved))
|
|
230
|
+
return null;
|
|
227
231
|
if (rootDir) {
|
|
228
232
|
const resolvedRoot = path.resolve(rootDir);
|
|
229
233
|
if (resolved === resolvedRoot ||
|
|
@@ -234,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
234
238
|
if (resolved === path.resolve(os.tmpdir()) ||
|
|
235
239
|
hasPathGlob(token) ||
|
|
236
240
|
isExistingDirectory(resolved)) {
|
|
237
|
-
return path.basename(os.tmpdir()) || os.tmpdir()
|
|
241
|
+
return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
|
|
238
242
|
}
|
|
239
243
|
return null;
|
|
240
244
|
}
|
|
@@ -250,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
|
|
|
250
254
|
const resolved = path.isAbsolute(token)
|
|
251
255
|
? path.resolve(token)
|
|
252
256
|
: path.resolve(baseDir ?? rootDir, token);
|
|
257
|
+
const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
|
|
258
|
+
if (osTempResolved)
|
|
259
|
+
return osTempResolved;
|
|
253
260
|
return getBlockedProjectPathDir(resolved, rootDir);
|
|
254
261
|
}
|
|
255
262
|
if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
|
|
@@ -386,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
|
|
|
386
393
|
}
|
|
387
394
|
return null;
|
|
388
395
|
}
|
|
396
|
+
function expandTempEnvRefs(command) {
|
|
397
|
+
return command
|
|
398
|
+
.replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
|
|
399
|
+
.replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
|
|
400
|
+
}
|
|
389
401
|
function findIgnoredPathInspection(command, rootDir) {
|
|
390
402
|
if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
|
|
391
403
|
return null;
|
|
392
404
|
}
|
|
393
|
-
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
|
|
405
|
+
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
|
|
394
406
|
const baseDir = getCommandBaseDir(haystack, rootDir);
|
|
395
407
|
const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
|
|
396
408
|
if (blockedDir) {
|
|
@@ -447,7 +459,7 @@ function shouldDropOutputLine(line, rootDir, baseDir) {
|
|
|
447
459
|
function isLsDirectoryHeader(line) {
|
|
448
460
|
return /^\.?\/?.+:$/.test(line) && !line.includes(' ');
|
|
449
461
|
}
|
|
450
|
-
function sanitizeCommandText(command, text, rootDir) {
|
|
462
|
+
export function sanitizeCommandText(command, text, rootDir) {
|
|
451
463
|
if (!text)
|
|
452
464
|
return '';
|
|
453
465
|
const lines = text.split('\n');
|
|
@@ -522,7 +534,7 @@ export function cancelActiveCommand() {
|
|
|
522
534
|
cancelled: true,
|
|
523
535
|
});
|
|
524
536
|
}
|
|
525
|
-
function terminateChild(child, signal) {
|
|
537
|
+
export function terminateChild(child, signal) {
|
|
526
538
|
if (!child?.pid)
|
|
527
539
|
return;
|
|
528
540
|
if (process.platform === 'win32') {
|
|
@@ -550,10 +562,17 @@ function terminateChild(child, signal) {
|
|
|
550
562
|
}
|
|
551
563
|
}
|
|
552
564
|
export function getBlockedPathInspectDir(command, rootDir) {
|
|
553
|
-
|
|
565
|
+
const blocked = findIgnoredPathInspection(command, rootDir);
|
|
566
|
+
return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
|
|
567
|
+
? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
|
|
568
|
+
: blocked;
|
|
554
569
|
}
|
|
555
570
|
export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
556
571
|
const ignoredDir = findIgnoredPathInspection(command, rootDir);
|
|
572
|
+
if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
|
|
573
|
+
const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
|
|
574
|
+
return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
|
|
575
|
+
}
|
|
557
576
|
if (ignoredDir) {
|
|
558
577
|
return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
|
|
559
578
|
}
|
|
@@ -572,7 +591,7 @@ export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
|
572
591
|
}
|
|
573
592
|
return null;
|
|
574
593
|
}
|
|
575
|
-
function commandUsesSudo(command) {
|
|
594
|
+
export function commandUsesSudo(command) {
|
|
576
595
|
return /\bsudo\b/.test(getUnquotedShellText(command));
|
|
577
596
|
}
|
|
578
597
|
export function sudoPromptFromTail(text) {
|
|
@@ -600,7 +619,7 @@ function redactSecrets(text, secrets) {
|
|
|
600
619
|
}
|
|
601
620
|
return redacted;
|
|
602
621
|
}
|
|
603
|
-
function buildCommandEnv(cwd) {
|
|
622
|
+
export function buildCommandEnv(cwd) {
|
|
604
623
|
const venvBin = detectVenvBin(cwd);
|
|
605
624
|
const envPath = buildCommandPath(process.env.PATH, venvBin ? [venvBin] : []);
|
|
606
625
|
return {
|
|
@@ -615,6 +634,7 @@ function buildCommandEnv(cwd) {
|
|
|
615
634
|
npm_config_fund: 'false',
|
|
616
635
|
npm_config_audit: 'false',
|
|
617
636
|
NUXI_INIT_SKIP_PROMPT: 'true',
|
|
637
|
+
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
618
638
|
};
|
|
619
639
|
}
|
|
620
640
|
function sanitizePtyOutput(command, output, cwd, secrets) {
|
package/dist/src/help-text.js
CHANGED
|
@@ -73,6 +73,10 @@ const HELP_MARKDOWN = [
|
|
|
73
73
|
'- `/model` — list supported models and pick one',
|
|
74
74
|
'- `/model <id>` — switch the active model without clearing history',
|
|
75
75
|
'- `/resume` — resume a saved session for this repo',
|
|
76
|
+
'- `/jobs` — open the background jobs picker (Enter expands/collapses,',
|
|
77
|
+
' k kills the selected job)',
|
|
78
|
+
'- `/jobs output <id>` — print one job\'s full captured output',
|
|
79
|
+
'- `/jobs kill <id>` — kill one background job',
|
|
76
80
|
'- `/clear` — clear the current conversation history',
|
|
77
81
|
'- `/exit` — quit the session',
|
|
78
82
|
'',
|
|
@@ -85,6 +89,12 @@ const HELP_MARKDOWN = [
|
|
|
85
89
|
' command and keeps the password masked and local.',
|
|
86
90
|
'- `-y` / `--yes` at startup auto-approves every shell command and file',
|
|
87
91
|
' edit for the whole session — use with care.',
|
|
92
|
+
'- Long-running commands (e.g. dev servers) can run as managed background',
|
|
93
|
+
' jobs after the same approval as any other command. Background output',
|
|
94
|
+
' stays quiet once the model has responded; the footer shows only a compact',
|
|
95
|
+
' shell-running indicator, `/jobs` lists, inspects, and kills jobs, and',
|
|
96
|
+
' killed jobs disappear immediately. Every job is killed when the session',
|
|
97
|
+
' ends.',
|
|
88
98
|
'- File and shell operations are confined to the target repo root.',
|
|
89
99
|
'- Sensitive directories (`.git`, `node_modules`, build output) are',
|
|
90
100
|
' never indexed.',
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
let cachedScratchDir = null;
|
|
5
|
+
export function sessionScratchDir() {
|
|
6
|
+
if (!cachedScratchDir) {
|
|
7
|
+
cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
|
|
8
|
+
}
|
|
9
|
+
return cachedScratchDir;
|
|
10
|
+
}
|
|
11
|
+
function isSquattedScratchRoot(dir) {
|
|
12
|
+
try {
|
|
13
|
+
const st = lstatSync(dir);
|
|
14
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
15
|
+
return true;
|
|
16
|
+
if (process.platform !== 'win32' &&
|
|
17
|
+
typeof process.getuid === 'function' &&
|
|
18
|
+
st.uid !== process.getuid()) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function ensureSessionScratchDir() {
|
|
28
|
+
const dir = sessionScratchDir();
|
|
29
|
+
try {
|
|
30
|
+
if (isSquattedScratchRoot(dir)) {
|
|
31
|
+
cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
32
|
+
return cachedScratchDir;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
chmodSync(dir, 0o700);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
}
|
|
41
|
+
return sessionScratchDir();
|
|
42
|
+
}
|
|
43
|
+
export function isInsideTheGitAiScratch(absPath) {
|
|
44
|
+
const tempRoot = path.resolve(os.tmpdir());
|
|
45
|
+
const relPath = path.relative(tempRoot, path.resolve(absPath));
|
|
46
|
+
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const first = relPath.split(/[\\/]/, 1)[0] ?? '';
|
|
50
|
+
if (!/^thegitai(?:$|[-.])/i.test(first)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (/[*?[\]{}]/.test(first)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return !isSquattedScratchRoot(path.join(tempRoot, first));
|
|
57
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
4
5
|
import { dispatchTool } from './tools/index.js';
|
|
6
|
+
import { syncIndexFromDisk } from './project-index.js';
|
|
5
7
|
import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
|
|
6
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
|
|
7
9
|
const EDIT_FILE_PATH_ARG_ALIASES = [
|
|
@@ -12,6 +14,7 @@ const EDIT_FILE_PATH_ARG_ALIASES = [
|
|
|
12
14
|
'file',
|
|
13
15
|
'filename',
|
|
14
16
|
];
|
|
17
|
+
const backgroundCommandTrackers = new Map();
|
|
15
18
|
function toolCallSummary(call) {
|
|
16
19
|
const args = call.args && typeof call.args === 'object' ? call.args : {};
|
|
17
20
|
if (call.name === 'run_command') {
|
|
@@ -20,6 +23,10 @@ function toolCallSummary(call) {
|
|
|
20
23
|
if (call.name === 'run_node_script') {
|
|
21
24
|
return String(args.script ?? '').trim().slice(0, 120);
|
|
22
25
|
}
|
|
26
|
+
if (call.name === 'shell_job_output' ||
|
|
27
|
+
call.name === 'shell_job_kill') {
|
|
28
|
+
return String(args.job_id ?? '').trim();
|
|
29
|
+
}
|
|
23
30
|
const filePath = getEditToolFilePath(call);
|
|
24
31
|
if (filePath)
|
|
25
32
|
return filePath;
|
|
@@ -53,6 +60,84 @@ function editToolWritesSeparateOutput(call) {
|
|
|
53
60
|
const output = args.outputPath ?? args.output_path;
|
|
54
61
|
return typeof output === 'string' && output.trim().length > 0;
|
|
55
62
|
}
|
|
63
|
+
async function collectTrackedCommandMutations({ session, projectIndex, result, tracker, toolName, toolCallId, turnId, }) {
|
|
64
|
+
const checkpoint = ensureActiveCheckpoint(session.clientState.safety, turnId);
|
|
65
|
+
const records = collectCommandMutations({
|
|
66
|
+
state: session.clientState.safety,
|
|
67
|
+
rootDir: session.rootDir,
|
|
68
|
+
tracker,
|
|
69
|
+
toolName,
|
|
70
|
+
toolCallId,
|
|
71
|
+
turnId,
|
|
72
|
+
checkpointId: checkpoint.id,
|
|
73
|
+
});
|
|
74
|
+
if (!records.length)
|
|
75
|
+
return;
|
|
76
|
+
invalidateShellDiagnosticsCache(session.rootDir);
|
|
77
|
+
rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
|
|
78
|
+
const repoSync = await syncIndexFromDisk(projectIndex);
|
|
79
|
+
result.repoSync = repoSync;
|
|
80
|
+
result.sessionEdits = records.map((record) => ({
|
|
81
|
+
id: record.id,
|
|
82
|
+
filePath: record.filePath,
|
|
83
|
+
operation: record.operation,
|
|
84
|
+
beforeHash: record.beforeHash,
|
|
85
|
+
afterHash: record.afterHash,
|
|
86
|
+
}));
|
|
87
|
+
result.diagnostics = runShellDiagnostics(session.rootDir);
|
|
88
|
+
}
|
|
89
|
+
export async function collectBackgroundJobUiKillMutations({ session, projectIndex, jobId, result, }) {
|
|
90
|
+
const normalizedJobId = String(jobId ?? result.snapshot?.id ?? '').trim();
|
|
91
|
+
if (!normalizedJobId || !result.ok)
|
|
92
|
+
return;
|
|
93
|
+
const tracked = backgroundCommandTrackers.get(normalizedJobId);
|
|
94
|
+
if (!tracked)
|
|
95
|
+
return;
|
|
96
|
+
const mutationResult = result;
|
|
97
|
+
await collectTrackedCommandMutations({
|
|
98
|
+
session,
|
|
99
|
+
projectIndex,
|
|
100
|
+
result: mutationResult,
|
|
101
|
+
tracker: tracked.tracker,
|
|
102
|
+
toolName: tracked.toolName,
|
|
103
|
+
toolCallId: tracked.toolCallId,
|
|
104
|
+
turnId: tracked.turnId,
|
|
105
|
+
});
|
|
106
|
+
if (result.snapshot?.status === 'running') {
|
|
107
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
backgroundCommandTrackers.delete(normalizedJobId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function collectBackgroundJobUiOutputMutations({ session, projectIndex, jobId, }) {
|
|
114
|
+
const normalizedJobId = String(jobId ?? '').trim();
|
|
115
|
+
if (!normalizedJobId)
|
|
116
|
+
return;
|
|
117
|
+
const tracked = backgroundCommandTrackers.get(normalizedJobId);
|
|
118
|
+
if (!tracked)
|
|
119
|
+
return;
|
|
120
|
+
const snapshot = getBackgroundJob(normalizedJobId, {
|
|
121
|
+
sessionId: session.sessionId,
|
|
122
|
+
});
|
|
123
|
+
if (!snapshot)
|
|
124
|
+
return;
|
|
125
|
+
await collectTrackedCommandMutations({
|
|
126
|
+
session,
|
|
127
|
+
projectIndex,
|
|
128
|
+
result: {},
|
|
129
|
+
tracker: tracked.tracker,
|
|
130
|
+
toolName: tracked.toolName,
|
|
131
|
+
toolCallId: tracked.toolCallId,
|
|
132
|
+
turnId: tracked.turnId,
|
|
133
|
+
});
|
|
134
|
+
if (snapshot.status === 'running') {
|
|
135
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
backgroundCommandTrackers.delete(normalizedJobId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
56
141
|
function recordAssistantEdit(session, call, result, before) {
|
|
57
142
|
if (!before || !isEditToolName(call.name))
|
|
58
143
|
return;
|
|
@@ -146,6 +231,7 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
146
231
|
: null;
|
|
147
232
|
const context = {
|
|
148
233
|
rootDir: session.rootDir,
|
|
234
|
+
sessionId: session.sessionId,
|
|
149
235
|
projectIndex: toolContext.projectIndex,
|
|
150
236
|
autoYes: session.autoYes,
|
|
151
237
|
confirmCommand: session.confirmCommand,
|
|
@@ -167,28 +253,57 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
167
253
|
};
|
|
168
254
|
const result = await dispatchTool(context, call);
|
|
169
255
|
recordAssistantEdit(session, call, result, beforeEditSnapshot);
|
|
170
|
-
if (result &&
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
256
|
+
if (result && typeof result === 'object' && commandTracker) {
|
|
257
|
+
await collectTrackedCommandMutations({
|
|
258
|
+
session,
|
|
259
|
+
projectIndex: toolContext.projectIndex,
|
|
260
|
+
result,
|
|
175
261
|
tracker: commandTracker,
|
|
176
262
|
toolName: call.name,
|
|
177
263
|
toolCallId: call.id,
|
|
178
264
|
turnId: session.turnState.id,
|
|
179
|
-
checkpointId: checkpoint.id,
|
|
180
265
|
});
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
result.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
})
|
|
191
|
-
|
|
266
|
+
if (call.name === 'run_command' &&
|
|
267
|
+
result.backgrounded === true &&
|
|
268
|
+
result.status === 'running' &&
|
|
269
|
+
result.jobId) {
|
|
270
|
+
backgroundCommandTrackers.set(String(result.jobId), {
|
|
271
|
+
tracker: captureMutationBaseline(session.rootDir),
|
|
272
|
+
toolName: call.name,
|
|
273
|
+
toolCallId: call.id,
|
|
274
|
+
turnId: session.turnState.id,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (result &&
|
|
279
|
+
typeof result === 'object' &&
|
|
280
|
+
(call.name === 'shell_job_output' || call.name === 'shell_job_kill')) {
|
|
281
|
+
const jobId = String(result.jobId ?? call.args?.job_id ?? '').trim();
|
|
282
|
+
const tracked = backgroundCommandTrackers.get(jobId);
|
|
283
|
+
if (tracked) {
|
|
284
|
+
await collectTrackedCommandMutations({
|
|
285
|
+
session,
|
|
286
|
+
projectIndex: toolContext.projectIndex,
|
|
287
|
+
result,
|
|
288
|
+
tracker: tracked.tracker,
|
|
289
|
+
toolName: tracked.toolName,
|
|
290
|
+
toolCallId: tracked.toolCallId,
|
|
291
|
+
turnId: tracked.turnId,
|
|
292
|
+
});
|
|
293
|
+
if (result.status === 'running') {
|
|
294
|
+
tracked.tracker = captureMutationBaseline(session.rootDir);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
backgroundCommandTrackers.delete(jobId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (result && typeof result === 'object') {
|
|
302
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
303
|
+
sessionId: session.sessionId,
|
|
304
|
+
});
|
|
305
|
+
if (backgroundJobUpdate) {
|
|
306
|
+
result.backgroundJobUpdate = backgroundJobUpdate;
|
|
192
307
|
}
|
|
193
308
|
}
|
|
194
309
|
session.onToolEvent?.({ call, result });
|
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,6 +18,7 @@ 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';
|
|
22
24
|
import { writeFile } from './write-file.js';
|
|
@@ -44,6 +46,8 @@ export const TOOL_MAP = {
|
|
|
44
46
|
undo_edit: undoEdit,
|
|
45
47
|
run_command: runShellCommand,
|
|
46
48
|
run_node_script: runNodeScript,
|
|
49
|
+
shell_job_output: shellJobOutput,
|
|
50
|
+
shell_job_kill: shellJobKill,
|
|
47
51
|
};
|
|
48
52
|
function invalidToolCall(error) {
|
|
49
53
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
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 || args.run_in_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 stopped.`
|
|
44
|
+
: command);
|
|
41
45
|
if (!approved) {
|
|
42
46
|
if (!isTuiMode())
|
|
43
47
|
console.log(chalk.dim(` ⏭ Skipped: ${command}`));
|
|
@@ -45,10 +49,13 @@ export async function runShellCommand(context, args) {
|
|
|
45
49
|
ok: false,
|
|
46
50
|
skipped: true,
|
|
47
51
|
command,
|
|
48
|
-
error: 'User declined command execution',
|
|
52
|
+
error: 'User declined command execution. Do not rerun this command or try a broader variant of it; either continue without it or ask the user one specific question about how to proceed.',
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
if (runInBackground) {
|
|
57
|
+
return runBackgroundCommand(context, command, args.timeout_ms, repoHint);
|
|
58
|
+
}
|
|
52
59
|
const result = await runCommand(command, rootDir, {
|
|
53
60
|
requestSudoPassword,
|
|
54
61
|
timeout: typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined,
|
|
@@ -67,17 +74,9 @@ export async function runShellCommand(context, args) {
|
|
|
67
74
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
68
75
|
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
69
76
|
}
|
|
70
|
-
|
|
71
|
-
? redactConnectionStringCredentials(result.output)
|
|
77
|
+
const output = typeof result.output === 'string'
|
|
78
|
+
? boundCommandOutput(redactConnectionStringCredentials(result.output))
|
|
72
79
|
: result.output;
|
|
73
|
-
if (typeof output === 'string' && output.length > MAX_OUTPUT_CHARS) {
|
|
74
|
-
const headSize = Math.floor(MAX_OUTPUT_CHARS * 0.2);
|
|
75
|
-
const tailSize = MAX_OUTPUT_CHARS - headSize;
|
|
76
|
-
output =
|
|
77
|
-
output.slice(0, headSize) +
|
|
78
|
-
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
79
|
-
output.slice(-tailSize);
|
|
80
|
-
}
|
|
81
80
|
return {
|
|
82
81
|
ok: result.exitCode === 0,
|
|
83
82
|
command,
|
|
@@ -90,3 +89,72 @@ export async function runShellCommand(context, args) {
|
|
|
90
89
|
repoHint,
|
|
91
90
|
};
|
|
92
91
|
}
|
|
92
|
+
export function boundCommandOutput(output) {
|
|
93
|
+
if (output.length <= MAX_OUTPUT_CHARS)
|
|
94
|
+
return output;
|
|
95
|
+
const headSize = Math.floor(MAX_OUTPUT_CHARS * 0.2);
|
|
96
|
+
const tailSize = MAX_OUTPUT_CHARS - headSize;
|
|
97
|
+
return (output.slice(0, headSize) +
|
|
98
|
+
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
99
|
+
output.slice(-tailSize));
|
|
100
|
+
}
|
|
101
|
+
async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
102
|
+
const { rootDir, projectIndex, onStatus } = context;
|
|
103
|
+
const started = await startBackgroundJob(command, rootDir, {
|
|
104
|
+
startupWaitMs: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined,
|
|
105
|
+
});
|
|
106
|
+
if (!started.ok || !started.snapshot) {
|
|
107
|
+
if (!isTuiMode())
|
|
108
|
+
console.log(chalk.red(`\n ✖ ${started.error}`));
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
blocked: true,
|
|
112
|
+
command,
|
|
113
|
+
error: started.error ?? 'Background job failed to start.',
|
|
114
|
+
repoHint,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const snapshot = started.snapshot;
|
|
118
|
+
const repoSync = projectIndex.initialized
|
|
119
|
+
? await syncIndexFromDisk(projectIndex)
|
|
120
|
+
: {
|
|
121
|
+
added: 0,
|
|
122
|
+
modified: 0,
|
|
123
|
+
removed: 0,
|
|
124
|
+
indexedChunks: 0,
|
|
125
|
+
retrievalTokensUsed: 0,
|
|
126
|
+
};
|
|
127
|
+
invalidateShellDiagnosticsCache(rootDir);
|
|
128
|
+
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
129
|
+
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
130
|
+
}
|
|
131
|
+
const output = boundCommandOutput(redactConnectionStringCredentials(started.startupOutput ?? '').trim());
|
|
132
|
+
if (snapshot.status === 'running') {
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
backgrounded: true,
|
|
136
|
+
command,
|
|
137
|
+
jobId: snapshot.id,
|
|
138
|
+
status: 'running',
|
|
139
|
+
pid: snapshot.pid,
|
|
140
|
+
output,
|
|
141
|
+
note: `Background job ${snapshot.id} is running. Use shell_job_output to poll status and new output, and shell_job_kill to stop it.`,
|
|
142
|
+
repoSync,
|
|
143
|
+
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
144
|
+
repoHint,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
ok: snapshot.exitCode === 0,
|
|
149
|
+
backgrounded: true,
|
|
150
|
+
command,
|
|
151
|
+
jobId: snapshot.id,
|
|
152
|
+
status: snapshot.status,
|
|
153
|
+
exitCode: snapshot.exitCode,
|
|
154
|
+
output,
|
|
155
|
+
note: `Background job ${snapshot.id} finished during the startup window.`,
|
|
156
|
+
repoSync,
|
|
157
|
+
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
158
|
+
repoHint,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -3,6 +3,7 @@ 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
|
});
|