topchester-ai 0.4.0 → 0.6.0
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/cli.mjs +898 -38
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
14
14
|
import { uuidv7 } from "uuidv7";
|
|
15
15
|
import { Input, Markdown, ProcessTerminal, TUI, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
16
16
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
17
|
-
import { execFile } from "node:child_process";
|
|
17
|
+
import { execFile, spawn } from "node:child_process";
|
|
18
18
|
//#region src/model/index.ts
|
|
19
19
|
var ModelGateway = class {
|
|
20
20
|
#config;
|
|
@@ -131,20 +131,20 @@ const TOPCHESTER_STATE_DIR = ".agents/topchester";
|
|
|
131
131
|
const TOPCHESTER_SESSIONS_DIR = `${TOPCHESTER_STATE_DIR}/sessions`;
|
|
132
132
|
const TOPCHESTER_LOGS_DIR = `${TOPCHESTER_STATE_DIR}/logs`;
|
|
133
133
|
`${TOPCHESTER_LOGS_DIR}`;
|
|
134
|
-
function resolveWorkspacePath(workspaceRoot, path) {
|
|
134
|
+
function resolveWorkspacePath$1(workspaceRoot, path) {
|
|
135
135
|
return isAbsolute(path) ? path : resolve(workspaceRoot, path);
|
|
136
136
|
}
|
|
137
137
|
function getTopchesterStatePath(workspaceRoot) {
|
|
138
|
-
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_STATE_DIR);
|
|
138
|
+
return resolveWorkspacePath$1(workspaceRoot, TOPCHESTER_STATE_DIR);
|
|
139
139
|
}
|
|
140
140
|
function getTopchesterSessionsPath(workspaceRoot) {
|
|
141
|
-
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_SESSIONS_DIR);
|
|
141
|
+
return resolveWorkspacePath$1(workspaceRoot, TOPCHESTER_SESSIONS_DIR);
|
|
142
142
|
}
|
|
143
143
|
function getTopchesterLogsPath(workspaceRoot) {
|
|
144
|
-
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_LOGS_DIR);
|
|
144
|
+
return resolveWorkspacePath$1(workspaceRoot, TOPCHESTER_LOGS_DIR);
|
|
145
145
|
}
|
|
146
146
|
function getTopchesterLogFilePath(workspaceRoot, logFile = process.env.TOPCHESTER_LOG_FILE) {
|
|
147
|
-
return logFile ? resolveWorkspacePath(workspaceRoot, logFile) : join(getTopchesterLogsPath(workspaceRoot), "topchester.log");
|
|
147
|
+
return logFile ? resolveWorkspacePath$1(workspaceRoot, logFile) : join(getTopchesterLogsPath(workspaceRoot), "topchester.log");
|
|
148
148
|
}
|
|
149
149
|
//#endregion
|
|
150
150
|
//#region src/logging/index.ts
|
|
@@ -321,8 +321,8 @@ function shouldUseColor() {
|
|
|
321
321
|
function getKnowledgeStatus(workspaceRoot) {
|
|
322
322
|
const kbPathSource = process.env.TOPCHESTER_KB_DIR ? "env" : "default";
|
|
323
323
|
const cachePathSource = process.env.TOPCHESTER_KB_CACHE_DIR ? "env" : "default";
|
|
324
|
-
const kbPath = resolveWorkspacePath(workspaceRoot, process.env.TOPCHESTER_KB_DIR ?? "topchester-kb");
|
|
325
|
-
const cachePath = resolveWorkspacePath(workspaceRoot, process.env.TOPCHESTER_KB_CACHE_DIR ?? ".agents/topchester-kb-cache");
|
|
324
|
+
const kbPath = resolveWorkspacePath$1(workspaceRoot, process.env.TOPCHESTER_KB_DIR ?? "topchester-kb");
|
|
325
|
+
const cachePath = resolveWorkspacePath$1(workspaceRoot, process.env.TOPCHESTER_KB_CACHE_DIR ?? ".agents/topchester-kb-cache");
|
|
326
326
|
const kbStat = safeStat(kbPath);
|
|
327
327
|
const cacheStat = safeStat(cachePath);
|
|
328
328
|
return {
|
|
@@ -1843,19 +1843,26 @@ function formatKnowledgeStatus(status) {
|
|
|
1843
1843
|
const lines = [
|
|
1844
1844
|
"KB status",
|
|
1845
1845
|
`workspace: ${status.workspaceRoot}`,
|
|
1846
|
-
`knowledge folder: ${
|
|
1847
|
-
`local cache folder: ${formatPathStatus$
|
|
1846
|
+
`knowledge folder: ${formatKnowledgePathStatus$2(status)} (${status.kbPathSource})`,
|
|
1847
|
+
`local cache folder: ${formatPathStatus$1(status.cachePath, status.cacheExists, status.cacheIsDirectory)} (${status.cachePathSource})`
|
|
1848
1848
|
];
|
|
1849
1849
|
if (!status.kbExists) lines.push("state: no knowledge base found yet");
|
|
1850
1850
|
else if (!status.kbIsDirectory) lines.push("state: knowledge base path is not a folder");
|
|
1851
|
+
else if (status.kbContentState !== "ready") lines.push("state: knowledge base folder is empty");
|
|
1851
1852
|
else lines.push("state: knowledge base found");
|
|
1852
1853
|
return lines;
|
|
1853
1854
|
}
|
|
1854
|
-
function formatPathStatus$
|
|
1855
|
+
function formatPathStatus$1(path, exists, isDirectory) {
|
|
1855
1856
|
if (!exists) return `${path} [missing]`;
|
|
1856
1857
|
if (!isDirectory) return `${path} [not a folder]`;
|
|
1857
1858
|
return `${path} [ok]`;
|
|
1858
1859
|
}
|
|
1860
|
+
function formatKnowledgePathStatus$2(status) {
|
|
1861
|
+
if (!status.kbExists) return `${status.kbPath} [missing]`;
|
|
1862
|
+
if (!status.kbIsDirectory) return `${status.kbPath} [not a folder]`;
|
|
1863
|
+
if (status.kbContentState !== "ready") return `${status.kbPath} [empty]`;
|
|
1864
|
+
return `${status.kbPath} [ok]`;
|
|
1865
|
+
}
|
|
1859
1866
|
//#endregion
|
|
1860
1867
|
//#region src/tui/markdown.ts
|
|
1861
1868
|
const codeFenceSentinel = "topchester-code-fence";
|
|
@@ -1976,8 +1983,12 @@ function renderSystemMessage(lines) {
|
|
|
1976
1983
|
return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
|
|
1977
1984
|
}
|
|
1978
1985
|
function formatSystemBodyLine(line) {
|
|
1986
|
+
if (isToolCallLine(line)) return ui.muted(line);
|
|
1979
1987
|
return line.replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary));
|
|
1980
1988
|
}
|
|
1989
|
+
function isToolCallLine(line) {
|
|
1990
|
+
return /^(read_file|list_files|grep|find_file|edit_file|inspect_command): /u.test(line);
|
|
1991
|
+
}
|
|
1981
1992
|
function getPrefix(kind) {
|
|
1982
1993
|
switch (kind) {
|
|
1983
1994
|
case "agent": return " ";
|
|
@@ -2058,6 +2069,48 @@ function escapeRegex(value) {
|
|
|
2058
2069
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2059
2070
|
}
|
|
2060
2071
|
//#endregion
|
|
2072
|
+
//#region src/tui/prompt-history.ts
|
|
2073
|
+
const DEFAULT_MAX_PROMPTS = 100;
|
|
2074
|
+
var PromptHistory = class {
|
|
2075
|
+
maxPrompts;
|
|
2076
|
+
prompts = [];
|
|
2077
|
+
historyIndex = -1;
|
|
2078
|
+
draft = "";
|
|
2079
|
+
constructor(maxPrompts = DEFAULT_MAX_PROMPTS) {
|
|
2080
|
+
this.maxPrompts = Math.max(1, maxPrompts);
|
|
2081
|
+
}
|
|
2082
|
+
add(value) {
|
|
2083
|
+
const prompt = value.trim();
|
|
2084
|
+
this.resetBrowsing();
|
|
2085
|
+
if (prompt.length === 0 || prompt === this.prompts[0]) return;
|
|
2086
|
+
this.prompts.unshift(prompt);
|
|
2087
|
+
this.prompts = this.prompts.slice(0, this.maxPrompts);
|
|
2088
|
+
}
|
|
2089
|
+
previous(currentDraft) {
|
|
2090
|
+
if (this.prompts.length === 0) return;
|
|
2091
|
+
if (this.historyIndex === -1) {
|
|
2092
|
+
this.draft = currentDraft;
|
|
2093
|
+
this.historyIndex = 0;
|
|
2094
|
+
return this.prompts[this.historyIndex];
|
|
2095
|
+
}
|
|
2096
|
+
this.historyIndex = Math.min(this.historyIndex + 1, this.prompts.length - 1);
|
|
2097
|
+
return this.prompts[this.historyIndex];
|
|
2098
|
+
}
|
|
2099
|
+
next() {
|
|
2100
|
+
if (this.historyIndex === -1) return;
|
|
2101
|
+
if (this.historyIndex === 0) {
|
|
2102
|
+
this.historyIndex = -1;
|
|
2103
|
+
return this.draft;
|
|
2104
|
+
}
|
|
2105
|
+
this.historyIndex -= 1;
|
|
2106
|
+
return this.prompts[this.historyIndex];
|
|
2107
|
+
}
|
|
2108
|
+
resetBrowsing() {
|
|
2109
|
+
this.historyIndex = -1;
|
|
2110
|
+
this.draft = "";
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
//#endregion
|
|
2061
2114
|
//#region src/tui/status.ts
|
|
2062
2115
|
function getStartupThreadMessages(context) {
|
|
2063
2116
|
const assignments = context.config.models?.assignments ?? {};
|
|
@@ -2136,10 +2189,17 @@ function formatKnowledgeFooterStatus(status) {
|
|
|
2136
2189
|
if (status.kbContentState !== "ready") return `${ui.label("○")} kb: ${ui.label("empty")}`;
|
|
2137
2190
|
return `${ui.ok("✅")} kb: ${ui.ok("ready")}`;
|
|
2138
2191
|
}
|
|
2139
|
-
function
|
|
2140
|
-
|
|
2141
|
-
if (!
|
|
2142
|
-
return `${
|
|
2192
|
+
function formatKnowledgePathStatus$1(status) {
|
|
2193
|
+
const pathLabel = `${ui.label("")} ${formatWorkspaceRelativePath(status.kbPath, status.workspaceRoot)}`;
|
|
2194
|
+
if (!status.kbExists) return `${pathLabel} ${ui.warn("[missing]")}`;
|
|
2195
|
+
if (!status.kbIsDirectory) return `${pathLabel} ${ui.error("[not a folder]")}`;
|
|
2196
|
+
if (status.kbContentState !== "ready") return `${pathLabel} ${ui.label("[empty]")}`;
|
|
2197
|
+
return `${pathLabel} ${ui.ok("[ok]")}`;
|
|
2198
|
+
}
|
|
2199
|
+
function formatWorkspaceRelativePath(path, workspaceRoot) {
|
|
2200
|
+
const relativePath = relative(workspaceRoot, path);
|
|
2201
|
+
if (relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath)) return relativePath;
|
|
2202
|
+
return path;
|
|
2143
2203
|
}
|
|
2144
2204
|
function getModelLabel(context) {
|
|
2145
2205
|
const purpose = context.config.models?.defaultPurpose ?? "agent.primary";
|
|
@@ -2188,6 +2248,7 @@ var ChatLayout = class {
|
|
|
2188
2248
|
activeModalActionIndex = 0;
|
|
2189
2249
|
activeSlashSuggestionIndex = 0;
|
|
2190
2250
|
threadScrollOffset = 0;
|
|
2251
|
+
promptHistory = new PromptHistory();
|
|
2191
2252
|
constructor(terminal, messages, folderName, modelLabel, exitAgent = () => {}) {
|
|
2192
2253
|
this.terminal = terminal;
|
|
2193
2254
|
this.messages = messages;
|
|
@@ -2264,8 +2325,11 @@ var ChatLayout = class {
|
|
|
2264
2325
|
}
|
|
2265
2326
|
if (this.handleModalInput(data)) return;
|
|
2266
2327
|
if (this.handleSlashSuggestionInput(data)) return;
|
|
2328
|
+
if (this.handlePromptHistoryInput(data)) return;
|
|
2267
2329
|
if (this.handleThreadScrollInput(data)) return;
|
|
2330
|
+
const previousInput = this.input.getValue();
|
|
2268
2331
|
this.input.handleInput(data);
|
|
2332
|
+
if (this.input.getValue() !== previousInput) this.promptHistory.resetBrowsing();
|
|
2269
2333
|
}
|
|
2270
2334
|
invalidate() {
|
|
2271
2335
|
this.input.invalidate();
|
|
@@ -2377,14 +2441,6 @@ var ChatLayout = class {
|
|
|
2377
2441
|
handleThreadScrollInput(data) {
|
|
2378
2442
|
const pageSize = Math.max(1, Math.floor(this.terminal.rows / 2));
|
|
2379
2443
|
const wheel = parseMouseWheel(data);
|
|
2380
|
-
if (isUpKey(data)) {
|
|
2381
|
-
this.threadScrollOffset += 3;
|
|
2382
|
-
return true;
|
|
2383
|
-
}
|
|
2384
|
-
if (isDownKey(data)) {
|
|
2385
|
-
this.threadScrollOffset = Math.max(0, this.threadScrollOffset - 3);
|
|
2386
|
-
return true;
|
|
2387
|
-
}
|
|
2388
2444
|
if (wheel === "up") {
|
|
2389
2445
|
this.threadScrollOffset += 3;
|
|
2390
2446
|
return true;
|
|
@@ -2411,6 +2467,20 @@ var ChatLayout = class {
|
|
|
2411
2467
|
}
|
|
2412
2468
|
return false;
|
|
2413
2469
|
}
|
|
2470
|
+
handlePromptHistoryInput(data) {
|
|
2471
|
+
if (this.promptHint) return false;
|
|
2472
|
+
if (isUpKey(data)) {
|
|
2473
|
+
const prompt = this.promptHistory.previous(this.input.getValue());
|
|
2474
|
+
if (prompt !== void 0) this.input.setValue(prompt);
|
|
2475
|
+
return true;
|
|
2476
|
+
}
|
|
2477
|
+
if (isDownKey(data)) {
|
|
2478
|
+
const prompt = this.promptHistory.next();
|
|
2479
|
+
if (prompt !== void 0) this.input.setValue(prompt);
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
2414
2484
|
handleSlashSuggestionInput(data) {
|
|
2415
2485
|
const suggestions = this.getSlashSuggestions();
|
|
2416
2486
|
if (suggestions.length === 0) {
|
|
@@ -2438,6 +2508,7 @@ var ChatLayout = class {
|
|
|
2438
2508
|
completeSlashSuggestion(suggestions) {
|
|
2439
2509
|
this.input.setValue(suggestions[this.activeSlashSuggestionIndex]?.value ?? this.input.getValue());
|
|
2440
2510
|
this.input.handleInput("\x1B[F");
|
|
2511
|
+
this.promptHistory.resetBrowsing();
|
|
2441
2512
|
}
|
|
2442
2513
|
getSlashSuggestions() {
|
|
2443
2514
|
return getSlashCommandSuggestions(this.input.getValue());
|
|
@@ -2453,6 +2524,7 @@ var ChatLayout = class {
|
|
|
2453
2524
|
this.submitUserInput(message);
|
|
2454
2525
|
}
|
|
2455
2526
|
submitUserInput(message) {
|
|
2527
|
+
this.promptHistory.add(message);
|
|
2456
2528
|
if (message.startsWith("/")) this.submitCommand?.(message);
|
|
2457
2529
|
else this.submitMessage?.(message);
|
|
2458
2530
|
}
|
|
@@ -2985,7 +3057,7 @@ async function collectWorkspaceFilesWithNode(workspaceRoot, startPath) {
|
|
|
2985
3057
|
return files;
|
|
2986
3058
|
}
|
|
2987
3059
|
async function createRipgrepCollector(pathEnv, relativeStartPath) {
|
|
2988
|
-
const command = await findExecutable$
|
|
3060
|
+
const command = await findExecutable$2("rg", pathEnv);
|
|
2989
3061
|
if (!command) return;
|
|
2990
3062
|
return {
|
|
2991
3063
|
name: "rg",
|
|
@@ -3001,8 +3073,8 @@ async function createRipgrepCollector(pathEnv, relativeStartPath) {
|
|
|
3001
3073
|
};
|
|
3002
3074
|
}
|
|
3003
3075
|
async function createFdCollector(pathEnv, relativeStartPath) {
|
|
3004
|
-
const fdCommand = await findExecutable$
|
|
3005
|
-
const fdfindCommand = fdCommand ? void 0 : await findExecutable$
|
|
3076
|
+
const fdCommand = await findExecutable$2("fd", pathEnv);
|
|
3077
|
+
const fdfindCommand = fdCommand ? void 0 : await findExecutable$2("fdfind", pathEnv);
|
|
3006
3078
|
const command = fdCommand ?? fdfindCommand;
|
|
3007
3079
|
if (!command) return;
|
|
3008
3080
|
return {
|
|
@@ -3022,7 +3094,7 @@ async function createFdCollector(pathEnv, relativeStartPath) {
|
|
|
3022
3094
|
};
|
|
3023
3095
|
}
|
|
3024
3096
|
async function createFindCollector(pathEnv, relativeStartPath) {
|
|
3025
|
-
const command = await findExecutable$
|
|
3097
|
+
const command = await findExecutable$2("find", pathEnv);
|
|
3026
3098
|
if (!command) return;
|
|
3027
3099
|
return {
|
|
3028
3100
|
name: "find",
|
|
@@ -3117,7 +3189,7 @@ function resolveWorkspaceScopedPath$2(workspaceRoot, path) {
|
|
|
3117
3189
|
relativePath: relativePath || "."
|
|
3118
3190
|
};
|
|
3119
3191
|
}
|
|
3120
|
-
async function findExecutable$
|
|
3192
|
+
async function findExecutable$2(name, pathEnv) {
|
|
3121
3193
|
for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
|
|
3122
3194
|
const executablePath = join(pathEntry, name);
|
|
3123
3195
|
try {
|
|
@@ -3241,14 +3313,14 @@ function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
|
|
|
3241
3313
|
}
|
|
3242
3314
|
async function findSearchExecutable(pathEnv = process.env.PATH ?? "") {
|
|
3243
3315
|
for (const name of ["rg", "grep"]) {
|
|
3244
|
-
const executablePath = await findExecutable(name, pathEnv);
|
|
3316
|
+
const executablePath = await findExecutable$1(name, pathEnv);
|
|
3245
3317
|
if (executablePath) return {
|
|
3246
3318
|
name,
|
|
3247
3319
|
path: executablePath
|
|
3248
3320
|
};
|
|
3249
3321
|
}
|
|
3250
3322
|
}
|
|
3251
|
-
async function findExecutable(name, pathEnv) {
|
|
3323
|
+
async function findExecutable$1(name, pathEnv) {
|
|
3252
3324
|
for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
|
|
3253
3325
|
const executablePath = join(pathEntry, name);
|
|
3254
3326
|
try {
|
|
@@ -3286,6 +3358,758 @@ function truncateToolOutput(output) {
|
|
|
3286
3358
|
function isRecord$1(value) {
|
|
3287
3359
|
return typeof value === "object" && value !== null;
|
|
3288
3360
|
}
|
|
3361
|
+
//#endregion
|
|
3362
|
+
//#region src/agent/tools/inspect-command-parser.ts
|
|
3363
|
+
const REJECTED_SYNTAX = [
|
|
3364
|
+
[/\r|\n/, "multiline commands are not allowed"],
|
|
3365
|
+
[/[<>]/, "redirects are not allowed"],
|
|
3366
|
+
[/\|&/, "stderr pipelines are not allowed"],
|
|
3367
|
+
[/\$\(|\$\{|\$/, "shell expansion is not allowed"],
|
|
3368
|
+
[/`/, "command substitution is not allowed"],
|
|
3369
|
+
[/[()]/, "subshells are not allowed"],
|
|
3370
|
+
[/[{}]/, "command groups are not allowed"],
|
|
3371
|
+
[/\*/, "globs are not allowed"],
|
|
3372
|
+
[/\?/, "globs are not allowed"],
|
|
3373
|
+
[/\[/, "globs are not allowed"],
|
|
3374
|
+
[/\]/, "globs are not allowed"]
|
|
3375
|
+
];
|
|
3376
|
+
function parseInspectCommand(command) {
|
|
3377
|
+
const trimmed = command.trim();
|
|
3378
|
+
if (!trimmed) throw new Error("inspect_command requires a command.");
|
|
3379
|
+
for (const [pattern, reason] of REJECTED_SYNTAX) if (pattern.test(trimmed)) throw new Error(`inspect_command rejected this command because ${reason}.`);
|
|
3380
|
+
const tokens = tokenize(trimmed);
|
|
3381
|
+
if (tokens.length === 0) throw new Error("inspect_command requires a command.");
|
|
3382
|
+
return {
|
|
3383
|
+
command: trimmed,
|
|
3384
|
+
entries: parseCommandList(tokens)
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
function tokenize(command) {
|
|
3388
|
+
const tokens = [];
|
|
3389
|
+
let index = 0;
|
|
3390
|
+
while (index < command.length) {
|
|
3391
|
+
const char = command[index];
|
|
3392
|
+
if (/\s/.test(char)) {
|
|
3393
|
+
index += 1;
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
if (command.startsWith("&&", index) || command.startsWith("||", index)) {
|
|
3397
|
+
tokens.push({
|
|
3398
|
+
type: "operator",
|
|
3399
|
+
value: command.slice(index, index + 2)
|
|
3400
|
+
});
|
|
3401
|
+
index += 2;
|
|
3402
|
+
continue;
|
|
3403
|
+
}
|
|
3404
|
+
if (char === "|" || char === ";") {
|
|
3405
|
+
tokens.push({
|
|
3406
|
+
type: "operator",
|
|
3407
|
+
value: char
|
|
3408
|
+
});
|
|
3409
|
+
index += 1;
|
|
3410
|
+
continue;
|
|
3411
|
+
}
|
|
3412
|
+
if (char === "&") throw new Error("inspect_command rejected this command because background jobs are not allowed.");
|
|
3413
|
+
const word = readWord(command, index);
|
|
3414
|
+
tokens.push({
|
|
3415
|
+
type: "word",
|
|
3416
|
+
value: word.value
|
|
3417
|
+
});
|
|
3418
|
+
index = word.nextIndex;
|
|
3419
|
+
}
|
|
3420
|
+
return tokens;
|
|
3421
|
+
}
|
|
3422
|
+
function readWord(command, startIndex) {
|
|
3423
|
+
let index = startIndex;
|
|
3424
|
+
let value = "";
|
|
3425
|
+
while (index < command.length) {
|
|
3426
|
+
const char = command[index];
|
|
3427
|
+
if (/\s/.test(char) || char === "|" || char === ";" || char === "&") break;
|
|
3428
|
+
if (char === "'") {
|
|
3429
|
+
const quoted = readQuotedWord(command, index + 1, "'");
|
|
3430
|
+
value += quoted.value;
|
|
3431
|
+
index = quoted.nextIndex;
|
|
3432
|
+
continue;
|
|
3433
|
+
}
|
|
3434
|
+
if (char === "\"") {
|
|
3435
|
+
const quoted = readQuotedWord(command, index + 1, "\"");
|
|
3436
|
+
value += quoted.value;
|
|
3437
|
+
index = quoted.nextIndex;
|
|
3438
|
+
continue;
|
|
3439
|
+
}
|
|
3440
|
+
value += char;
|
|
3441
|
+
index += 1;
|
|
3442
|
+
}
|
|
3443
|
+
if (!value) throw new Error("inspect_command rejected this command because empty words are not allowed.");
|
|
3444
|
+
return {
|
|
3445
|
+
value,
|
|
3446
|
+
nextIndex: index
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
function readQuotedWord(command, startIndex, quote) {
|
|
3450
|
+
let index = startIndex;
|
|
3451
|
+
let value = "";
|
|
3452
|
+
while (index < command.length) {
|
|
3453
|
+
const char = command[index];
|
|
3454
|
+
if (char === quote) return {
|
|
3455
|
+
value,
|
|
3456
|
+
nextIndex: index + 1
|
|
3457
|
+
};
|
|
3458
|
+
value += char;
|
|
3459
|
+
index += 1;
|
|
3460
|
+
}
|
|
3461
|
+
throw new Error("inspect_command rejected this command because quoted strings must be closed.");
|
|
3462
|
+
}
|
|
3463
|
+
function parseCommandList(tokens) {
|
|
3464
|
+
const entries = [];
|
|
3465
|
+
let index = 0;
|
|
3466
|
+
let operator = "start";
|
|
3467
|
+
while (index < tokens.length) {
|
|
3468
|
+
if (tokens[index]?.type === "operator") throw new Error("inspect_command rejected this command because operators must appear between commands.");
|
|
3469
|
+
const parsed = parsePipeline(tokens, index);
|
|
3470
|
+
entries.push({
|
|
3471
|
+
operator,
|
|
3472
|
+
pipeline: parsed.pipeline
|
|
3473
|
+
});
|
|
3474
|
+
index = parsed.nextIndex;
|
|
3475
|
+
if (index >= tokens.length) break;
|
|
3476
|
+
const token = tokens[index];
|
|
3477
|
+
if (token?.type !== "operator" || token.value === "|") throw new Error("inspect_command rejected this command because pipelines must contain commands on both sides.");
|
|
3478
|
+
operator = token.value;
|
|
3479
|
+
index += 1;
|
|
3480
|
+
if (index >= tokens.length) throw new Error("inspect_command rejected this command because operators must appear between commands.");
|
|
3481
|
+
}
|
|
3482
|
+
return entries;
|
|
3483
|
+
}
|
|
3484
|
+
function parsePipeline(tokens, startIndex) {
|
|
3485
|
+
const commands = [];
|
|
3486
|
+
let index = startIndex;
|
|
3487
|
+
while (index < tokens.length) {
|
|
3488
|
+
const parsed = parseSimpleCommand(tokens, index);
|
|
3489
|
+
commands.push(parsed.command);
|
|
3490
|
+
index = parsed.nextIndex;
|
|
3491
|
+
const token = tokens[index];
|
|
3492
|
+
if (token?.type !== "operator" || token.value !== "|") break;
|
|
3493
|
+
index += 1;
|
|
3494
|
+
if (index >= tokens.length || tokens[index]?.type === "operator") throw new Error("inspect_command rejected this command because pipelines must contain commands on both sides.");
|
|
3495
|
+
}
|
|
3496
|
+
return {
|
|
3497
|
+
pipeline: { commands },
|
|
3498
|
+
nextIndex: index
|
|
3499
|
+
};
|
|
3500
|
+
}
|
|
3501
|
+
function parseSimpleCommand(tokens, startIndex) {
|
|
3502
|
+
const words = [];
|
|
3503
|
+
let index = startIndex;
|
|
3504
|
+
while (index < tokens.length) {
|
|
3505
|
+
const token = tokens[index];
|
|
3506
|
+
if (token?.type !== "word") break;
|
|
3507
|
+
words.push(token.value);
|
|
3508
|
+
index += 1;
|
|
3509
|
+
}
|
|
3510
|
+
if (words.length === 0) throw new Error("inspect_command rejected this command because empty commands are not allowed.");
|
|
3511
|
+
return {
|
|
3512
|
+
command: {
|
|
3513
|
+
executable: words[0] ?? "",
|
|
3514
|
+
args: words.slice(1)
|
|
3515
|
+
},
|
|
3516
|
+
nextIndex: index
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
//#endregion
|
|
3520
|
+
//#region src/agent/tools/inspect-command-policy.ts
|
|
3521
|
+
const inspectCommandArgsSchema = z.object({
|
|
3522
|
+
command: z.string().min(1).max(2e3),
|
|
3523
|
+
workdir: z.string().optional().default("."),
|
|
3524
|
+
timeout_ms: z.number().int().min(100).max(1e4).optional().default(1e4)
|
|
3525
|
+
});
|
|
3526
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
3527
|
+
"pwd",
|
|
3528
|
+
"ls",
|
|
3529
|
+
"rg",
|
|
3530
|
+
"grep",
|
|
3531
|
+
"find",
|
|
3532
|
+
"fd",
|
|
3533
|
+
"cat",
|
|
3534
|
+
"head",
|
|
3535
|
+
"tail",
|
|
3536
|
+
"wc",
|
|
3537
|
+
"stat",
|
|
3538
|
+
"file",
|
|
3539
|
+
"du",
|
|
3540
|
+
"git"
|
|
3541
|
+
]);
|
|
3542
|
+
const PATHLESS_GIT_SUBCOMMANDS = new Set([
|
|
3543
|
+
"status",
|
|
3544
|
+
"log",
|
|
3545
|
+
"diff",
|
|
3546
|
+
"show",
|
|
3547
|
+
"branch",
|
|
3548
|
+
"rev-parse",
|
|
3549
|
+
"ls-files"
|
|
3550
|
+
]);
|
|
3551
|
+
const GIT_OPTIONS_WITH_PATH_VALUES = new Set(["--", "--pathspec-from-file"]);
|
|
3552
|
+
const COMMON_OPTIONS_WITH_VALUES = new Set([
|
|
3553
|
+
"-A",
|
|
3554
|
+
"-B",
|
|
3555
|
+
"-C",
|
|
3556
|
+
"-c",
|
|
3557
|
+
"-e",
|
|
3558
|
+
"-m",
|
|
3559
|
+
"-n",
|
|
3560
|
+
"-S",
|
|
3561
|
+
"-s",
|
|
3562
|
+
"--after-context",
|
|
3563
|
+
"--before-context",
|
|
3564
|
+
"--color",
|
|
3565
|
+
"--context",
|
|
3566
|
+
"--encoding",
|
|
3567
|
+
"--glob",
|
|
3568
|
+
"--heading",
|
|
3569
|
+
"--ignore-file",
|
|
3570
|
+
"--max-count",
|
|
3571
|
+
"--max-depth",
|
|
3572
|
+
"--max-filesize",
|
|
3573
|
+
"--sort",
|
|
3574
|
+
"--sort-files",
|
|
3575
|
+
"--type",
|
|
3576
|
+
"--type-add"
|
|
3577
|
+
]);
|
|
3578
|
+
const FIND_OPTIONS_WITH_VALUES = new Set([
|
|
3579
|
+
"-name",
|
|
3580
|
+
"-iname",
|
|
3581
|
+
"-path",
|
|
3582
|
+
"-ipath",
|
|
3583
|
+
"-type",
|
|
3584
|
+
"-maxdepth",
|
|
3585
|
+
"-mindepth",
|
|
3586
|
+
"-size",
|
|
3587
|
+
"-mtime",
|
|
3588
|
+
"-newer"
|
|
3589
|
+
]);
|
|
3590
|
+
const FD_OPTIONS_WITH_VALUES = new Set([
|
|
3591
|
+
"-e",
|
|
3592
|
+
"-t",
|
|
3593
|
+
"-d",
|
|
3594
|
+
"--extension",
|
|
3595
|
+
"--type",
|
|
3596
|
+
"--max-depth",
|
|
3597
|
+
"--min-depth"
|
|
3598
|
+
]);
|
|
3599
|
+
const SAFE_PATHLESS_FLAGS = new Set([
|
|
3600
|
+
"--",
|
|
3601
|
+
"-0",
|
|
3602
|
+
"-1",
|
|
3603
|
+
"-a",
|
|
3604
|
+
"-A",
|
|
3605
|
+
"-B",
|
|
3606
|
+
"-C",
|
|
3607
|
+
"-F",
|
|
3608
|
+
"-G",
|
|
3609
|
+
"-H",
|
|
3610
|
+
"-L",
|
|
3611
|
+
"-R",
|
|
3612
|
+
"-S",
|
|
3613
|
+
"-a",
|
|
3614
|
+
"-b",
|
|
3615
|
+
"-c",
|
|
3616
|
+
"-d",
|
|
3617
|
+
"-f",
|
|
3618
|
+
"-g",
|
|
3619
|
+
"-h",
|
|
3620
|
+
"-i",
|
|
3621
|
+
"-l",
|
|
3622
|
+
"-m",
|
|
3623
|
+
"-n",
|
|
3624
|
+
"-p",
|
|
3625
|
+
"-r",
|
|
3626
|
+
"-s",
|
|
3627
|
+
"-t",
|
|
3628
|
+
"-u",
|
|
3629
|
+
"-v",
|
|
3630
|
+
"-w",
|
|
3631
|
+
"-x",
|
|
3632
|
+
"--all",
|
|
3633
|
+
"--brief",
|
|
3634
|
+
"--bytes",
|
|
3635
|
+
"--color",
|
|
3636
|
+
"--count",
|
|
3637
|
+
"--dereference",
|
|
3638
|
+
"--files",
|
|
3639
|
+
"--follow",
|
|
3640
|
+
"--heading",
|
|
3641
|
+
"--hidden",
|
|
3642
|
+
"--ignore-case",
|
|
3643
|
+
"--line-number",
|
|
3644
|
+
"--long",
|
|
3645
|
+
"--max-depth",
|
|
3646
|
+
"--no-heading",
|
|
3647
|
+
"--no-ignore",
|
|
3648
|
+
"--null",
|
|
3649
|
+
"--oneline",
|
|
3650
|
+
"--print",
|
|
3651
|
+
"--recursive",
|
|
3652
|
+
"--short",
|
|
3653
|
+
"--show-current",
|
|
3654
|
+
"--show-toplevel",
|
|
3655
|
+
"--sort",
|
|
3656
|
+
"--word-regexp"
|
|
3657
|
+
]);
|
|
3658
|
+
const DENIED_FLAGS = new Set([
|
|
3659
|
+
"--hostname-bin",
|
|
3660
|
+
"--pre",
|
|
3661
|
+
"--pre-glob",
|
|
3662
|
+
"--search-zip",
|
|
3663
|
+
"-z",
|
|
3664
|
+
"-Z",
|
|
3665
|
+
"-delete",
|
|
3666
|
+
"-exec",
|
|
3667
|
+
"-execdir",
|
|
3668
|
+
"-ok",
|
|
3669
|
+
"-okdir",
|
|
3670
|
+
"-fls",
|
|
3671
|
+
"-fprintf",
|
|
3672
|
+
"-fprint",
|
|
3673
|
+
"--exec",
|
|
3674
|
+
"-x",
|
|
3675
|
+
"--exec-batch",
|
|
3676
|
+
"-X"
|
|
3677
|
+
]);
|
|
3678
|
+
function validateInspectCommand(args, context) {
|
|
3679
|
+
let plan;
|
|
3680
|
+
try {
|
|
3681
|
+
plan = parseInspectCommand(args.command);
|
|
3682
|
+
} catch (error) {
|
|
3683
|
+
return {
|
|
3684
|
+
allowed: false,
|
|
3685
|
+
reason: error instanceof Error ? error.message : "inspect_command rejected this command.",
|
|
3686
|
+
commands: []
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
const commands = plan.entries.flatMap((entry) => entry.pipeline.commands.map(formatSimpleCommand));
|
|
3690
|
+
const workspace = resolve(context.workspaceRoot);
|
|
3691
|
+
const workdir = resolveWorkspacePath(workspace, context.workdir ?? args.workdir);
|
|
3692
|
+
if (!workdir.allowed) return {
|
|
3693
|
+
allowed: false,
|
|
3694
|
+
reason: workdir.reason,
|
|
3695
|
+
commands,
|
|
3696
|
+
plan
|
|
3697
|
+
};
|
|
3698
|
+
for (const command of plan.entries.flatMap((entry) => entry.pipeline.commands)) {
|
|
3699
|
+
const result = validateSimpleCommand(command, {
|
|
3700
|
+
workspaceRoot: workspace,
|
|
3701
|
+
cwd: workdir.path
|
|
3702
|
+
});
|
|
3703
|
+
if (!result.allowed) return {
|
|
3704
|
+
allowed: false,
|
|
3705
|
+
reason: result.reason,
|
|
3706
|
+
commands,
|
|
3707
|
+
plan
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
return {
|
|
3711
|
+
allowed: true,
|
|
3712
|
+
reason: "command uses the inspect_command read-only allowlist",
|
|
3713
|
+
commands,
|
|
3714
|
+
plan
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
function validateSimpleCommand(command, context) {
|
|
3718
|
+
if (command.executable.includes("/") || command.executable === "cd") return {
|
|
3719
|
+
allowed: false,
|
|
3720
|
+
reason: `inspect_command rejected '${command.executable}' because it is not allowed.`
|
|
3721
|
+
};
|
|
3722
|
+
if (!READ_ONLY_COMMANDS.has(command.executable)) return {
|
|
3723
|
+
allowed: false,
|
|
3724
|
+
reason: `inspect_command rejected '${command.executable}' because it is not allowed.`
|
|
3725
|
+
};
|
|
3726
|
+
if (command.args.some((arg) => DENIED_FLAGS.has(arg))) {
|
|
3727
|
+
const flag = command.args.find((arg) => DENIED_FLAGS.has(arg));
|
|
3728
|
+
return {
|
|
3729
|
+
allowed: false,
|
|
3730
|
+
reason: `inspect_command rejected '${command.executable}' because '${flag}' is unsafe.`
|
|
3731
|
+
};
|
|
3732
|
+
}
|
|
3733
|
+
switch (command.executable) {
|
|
3734
|
+
case "pwd": return command.args.length === 0 ? { allowed: true } : {
|
|
3735
|
+
allowed: false,
|
|
3736
|
+
reason: "inspect_command rejected 'pwd' because it does not accept path arguments."
|
|
3737
|
+
};
|
|
3738
|
+
case "git": return validateGitCommand(command, context);
|
|
3739
|
+
case "find": return validateFindCommand(command, context);
|
|
3740
|
+
case "fd": return validateGenericCommandArgs(command, context, FD_OPTIONS_WITH_VALUES);
|
|
3741
|
+
default: return validateGenericCommandArgs(command, context, COMMON_OPTIONS_WITH_VALUES);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
function validateGitCommand(command, context) {
|
|
3745
|
+
const subcommand = command.args.find((arg) => !arg.startsWith("-"));
|
|
3746
|
+
if (!subcommand || !PATHLESS_GIT_SUBCOMMANDS.has(subcommand)) return {
|
|
3747
|
+
allowed: false,
|
|
3748
|
+
reason: "inspect_command rejected 'git' because only read-only git subcommands are allowed."
|
|
3749
|
+
};
|
|
3750
|
+
if (subcommand === "branch" && command.args.some((arg) => arg !== "branch" && arg !== "--show-current")) return {
|
|
3751
|
+
allowed: false,
|
|
3752
|
+
reason: "inspect_command rejected 'git branch' because only --show-current is allowed."
|
|
3753
|
+
};
|
|
3754
|
+
if (subcommand === "rev-parse" && command.args.some((arg) => arg !== "rev-parse" && arg !== "--show-toplevel")) return {
|
|
3755
|
+
allowed: false,
|
|
3756
|
+
reason: "inspect_command rejected 'git rev-parse' because only --show-toplevel is allowed."
|
|
3757
|
+
};
|
|
3758
|
+
return validateGenericCommandArgs(command, context, GIT_OPTIONS_WITH_PATH_VALUES, new Set(["git", subcommand]));
|
|
3759
|
+
}
|
|
3760
|
+
function validateFindCommand(command, context) {
|
|
3761
|
+
return validateGenericCommandArgs(command, context, FIND_OPTIONS_WITH_VALUES);
|
|
3762
|
+
}
|
|
3763
|
+
function validateGenericCommandArgs(command, context, optionsWithValues, knownPathlessWords = /* @__PURE__ */ new Set()) {
|
|
3764
|
+
for (let index = 0; index < command.args.length; index += 1) {
|
|
3765
|
+
const arg = command.args[index] ?? "";
|
|
3766
|
+
if (knownPathlessWords.has(arg)) continue;
|
|
3767
|
+
if (optionsWithValues.has(arg)) {
|
|
3768
|
+
index += 1;
|
|
3769
|
+
continue;
|
|
3770
|
+
}
|
|
3771
|
+
if (arg.startsWith("--") && arg.includes("=")) {
|
|
3772
|
+
const [flag, value] = arg.split("=", 2);
|
|
3773
|
+
if (DENIED_FLAGS.has(flag ?? "")) return {
|
|
3774
|
+
allowed: false,
|
|
3775
|
+
reason: `inspect_command rejected '${command.executable}' because '${flag}' is unsafe.`
|
|
3776
|
+
};
|
|
3777
|
+
if (looksLikePath(value ?? "")) {
|
|
3778
|
+
const scoped = resolveWorkspacePath(context.workspaceRoot, value ?? "", context.cwd);
|
|
3779
|
+
if (!scoped.allowed) return {
|
|
3780
|
+
allowed: false,
|
|
3781
|
+
reason: scoped.reason
|
|
3782
|
+
};
|
|
3783
|
+
}
|
|
3784
|
+
continue;
|
|
3785
|
+
}
|
|
3786
|
+
if (arg.startsWith("-")) {
|
|
3787
|
+
if (SAFE_PATHLESS_FLAGS.has(arg) || /^-[A-Za-z0-9]+$/.test(arg)) continue;
|
|
3788
|
+
return {
|
|
3789
|
+
allowed: false,
|
|
3790
|
+
reason: `inspect_command rejected '${command.executable}' because '${arg}' is not allowed.`
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3793
|
+
if (!looksLikePath(arg)) continue;
|
|
3794
|
+
const scoped = resolveWorkspacePath(context.workspaceRoot, arg, context.cwd);
|
|
3795
|
+
if (!scoped.allowed) return {
|
|
3796
|
+
allowed: false,
|
|
3797
|
+
reason: scoped.reason
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
return { allowed: true };
|
|
3801
|
+
}
|
|
3802
|
+
function resolveWorkspacePath(workspaceRoot, path, cwd = workspaceRoot) {
|
|
3803
|
+
const resolved = isAbsolute(path) ? resolve(path) : resolve(cwd, path);
|
|
3804
|
+
const relativePath = relative(workspaceRoot, resolved);
|
|
3805
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return {
|
|
3806
|
+
allowed: false,
|
|
3807
|
+
reason: `inspect_command rejected path outside the workspace: ${path}`
|
|
3808
|
+
};
|
|
3809
|
+
return {
|
|
3810
|
+
allowed: true,
|
|
3811
|
+
path: resolved
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
function looksLikePath(arg) {
|
|
3815
|
+
return arg === "." || arg === ".." || arg.startsWith("./") || arg.startsWith("../") || arg.startsWith("/") || arg.includes("/");
|
|
3816
|
+
}
|
|
3817
|
+
function formatSimpleCommand(command) {
|
|
3818
|
+
return [command.executable, ...command.args].join(" ");
|
|
3819
|
+
}
|
|
3820
|
+
//#endregion
|
|
3821
|
+
//#region src/agent/tools/inspect-command.ts
|
|
3822
|
+
const MAX_OUTPUT_BYTES = 4e4;
|
|
3823
|
+
const MAX_OUTPUT_LINES = 1e3;
|
|
3824
|
+
const inspectCommandTool = defineTool({
|
|
3825
|
+
name: "inspect_command",
|
|
3826
|
+
description: "Run a narrowly validated read-only command for repository orientation.",
|
|
3827
|
+
prompt: "inspect_command: run a safe read-only discovery command inside the workspace for quick orientation; prefer read_file, list_files, grep, and find_file for exact file tasks, and do not use it for builds, tests, installs, network, shell scripts, or edits. To use it, reply with only JSON: {\"tool\":\"inspect_command\",\"args\":{\"command\":\"pwd && rg --files docs/plans | head -20\",\"workdir\":\".\",\"timeout_ms\":10000}}",
|
|
3828
|
+
argsSchema: inspectCommandArgsSchema,
|
|
3829
|
+
execute: (context, args) => inspectWorkspaceCommand(context.workspaceRoot, args, { pathEnv: context.pathEnv })
|
|
3830
|
+
});
|
|
3831
|
+
async function inspectWorkspaceCommand(workspaceRoot, args, options = {}) {
|
|
3832
|
+
const startedAt = Date.now();
|
|
3833
|
+
const decision = validateInspectCommand(args, { workspaceRoot });
|
|
3834
|
+
if (!decision.allowed) throw new Error(decision.reason);
|
|
3835
|
+
const resolvedWorkspace = await realpath(resolve(workspaceRoot));
|
|
3836
|
+
const cwd = await resolveWorkspaceCwd(resolvedWorkspace, args.workdir);
|
|
3837
|
+
const deadlineAt = startedAt + args.timeout_ms;
|
|
3838
|
+
const result = await executePlan(decision.plan, {
|
|
3839
|
+
cwd,
|
|
3840
|
+
pathEnv: options.pathEnv ?? process.env.PATH ?? "",
|
|
3841
|
+
deadlineAt
|
|
3842
|
+
});
|
|
3843
|
+
const durationMs = Date.now() - startedAt;
|
|
3844
|
+
return {
|
|
3845
|
+
tool: "inspect_command",
|
|
3846
|
+
command: args.command,
|
|
3847
|
+
cwd: relative(resolvedWorkspace, cwd) || ".",
|
|
3848
|
+
content: formatInspectCommandContent(result),
|
|
3849
|
+
exitCode: result.exitCode,
|
|
3850
|
+
durationMs,
|
|
3851
|
+
timedOut: result.timedOut,
|
|
3852
|
+
truncated: result.truncated,
|
|
3853
|
+
warning: result.missingExecutable ? `inspect_command could not run because '${result.missingExecutable}' is not available on PATH.` : result.timedOut ? "inspect_command timed out." : result.truncated ? "inspect_command output was truncated." : void 0,
|
|
3854
|
+
decision: {
|
|
3855
|
+
allowed: true,
|
|
3856
|
+
reason: decision.reason,
|
|
3857
|
+
commands: decision.commands
|
|
3858
|
+
},
|
|
3859
|
+
stdout: result.stdout,
|
|
3860
|
+
stderr: result.stderr
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
async function executePlan(plan, context) {
|
|
3864
|
+
let lastExitCode = 0;
|
|
3865
|
+
let stdout = "";
|
|
3866
|
+
let stderr = "";
|
|
3867
|
+
let timedOut = false;
|
|
3868
|
+
let truncated = false;
|
|
3869
|
+
for (const entry of plan.entries) {
|
|
3870
|
+
if (!shouldExecuteEntry(entry, lastExitCode)) continue;
|
|
3871
|
+
const remainingMs = getRemainingTimeoutMs(context.deadlineAt);
|
|
3872
|
+
if (remainingMs <= 0) return {
|
|
3873
|
+
stdout,
|
|
3874
|
+
stderr,
|
|
3875
|
+
exitCode: lastExitCode,
|
|
3876
|
+
timedOut: true,
|
|
3877
|
+
truncated
|
|
3878
|
+
};
|
|
3879
|
+
const result = await executePipeline(entry.pipeline, {
|
|
3880
|
+
...context,
|
|
3881
|
+
timeoutMs: remainingMs
|
|
3882
|
+
});
|
|
3883
|
+
stdout = appendBoundedOutput(stdout, result.stdout).output;
|
|
3884
|
+
const nextStderr = appendBoundedOutput(stderr, result.stderr);
|
|
3885
|
+
stderr = nextStderr.output;
|
|
3886
|
+
truncated = truncated || result.truncated || nextStderr.truncated;
|
|
3887
|
+
timedOut = timedOut || result.timedOut;
|
|
3888
|
+
lastExitCode = result.exitCode;
|
|
3889
|
+
if (result.missingExecutable || result.timedOut) return {
|
|
3890
|
+
stdout,
|
|
3891
|
+
stderr,
|
|
3892
|
+
exitCode: result.exitCode,
|
|
3893
|
+
timedOut,
|
|
3894
|
+
truncated,
|
|
3895
|
+
missingExecutable: result.missingExecutable
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
return {
|
|
3899
|
+
stdout,
|
|
3900
|
+
stderr,
|
|
3901
|
+
exitCode: lastExitCode,
|
|
3902
|
+
timedOut,
|
|
3903
|
+
truncated
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3906
|
+
async function executePipeline(pipeline, context) {
|
|
3907
|
+
let input = "";
|
|
3908
|
+
let stderr = "";
|
|
3909
|
+
let exitCode = 0;
|
|
3910
|
+
let timedOut = false;
|
|
3911
|
+
let truncated = false;
|
|
3912
|
+
for (const command of pipeline.commands) {
|
|
3913
|
+
const remainingMs = getRemainingTimeoutMs(context.deadlineAt);
|
|
3914
|
+
if (remainingMs <= 0) return {
|
|
3915
|
+
stdout: input,
|
|
3916
|
+
stderr,
|
|
3917
|
+
exitCode,
|
|
3918
|
+
timedOut: true,
|
|
3919
|
+
truncated
|
|
3920
|
+
};
|
|
3921
|
+
const result = await executeSimpleCommand(command, input, {
|
|
3922
|
+
...context,
|
|
3923
|
+
timeoutMs: remainingMs
|
|
3924
|
+
});
|
|
3925
|
+
input = result.stdout;
|
|
3926
|
+
const nextStderr = appendBoundedOutput(stderr, result.stderr);
|
|
3927
|
+
stderr = nextStderr.output;
|
|
3928
|
+
exitCode = result.exitCode;
|
|
3929
|
+
timedOut = timedOut || result.timedOut;
|
|
3930
|
+
truncated = truncated || result.truncated || nextStderr.truncated;
|
|
3931
|
+
if (result.missingExecutable || result.timedOut || result.exitCode !== 0) return {
|
|
3932
|
+
stdout: input,
|
|
3933
|
+
stderr,
|
|
3934
|
+
exitCode,
|
|
3935
|
+
timedOut,
|
|
3936
|
+
truncated,
|
|
3937
|
+
missingExecutable: result.missingExecutable
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
3940
|
+
return {
|
|
3941
|
+
stdout: input,
|
|
3942
|
+
stderr,
|
|
3943
|
+
exitCode,
|
|
3944
|
+
timedOut,
|
|
3945
|
+
truncated
|
|
3946
|
+
};
|
|
3947
|
+
}
|
|
3948
|
+
async function executeSimpleCommand(command, input, context) {
|
|
3949
|
+
if (command.executable === "pwd") return {
|
|
3950
|
+
stdout: `${context.cwd}\n`,
|
|
3951
|
+
stderr: "",
|
|
3952
|
+
exitCode: 0,
|
|
3953
|
+
timedOut: false,
|
|
3954
|
+
truncated: false
|
|
3955
|
+
};
|
|
3956
|
+
const executablePath = await findExecutable(command.executable, context.pathEnv);
|
|
3957
|
+
if (!executablePath) return {
|
|
3958
|
+
stdout: "",
|
|
3959
|
+
stderr: `inspect_command could not run because '${command.executable}' is not available on PATH.\n`,
|
|
3960
|
+
exitCode: 127,
|
|
3961
|
+
timedOut: false,
|
|
3962
|
+
truncated: false,
|
|
3963
|
+
missingExecutable: command.executable
|
|
3964
|
+
};
|
|
3965
|
+
return runSpawnedCommand(executablePath, command.args, input, context);
|
|
3966
|
+
}
|
|
3967
|
+
function runSpawnedCommand(command, args, input, context) {
|
|
3968
|
+
return new Promise((resolveCommand) => {
|
|
3969
|
+
const detached = process.platform !== "win32";
|
|
3970
|
+
const child = spawn(command, args, {
|
|
3971
|
+
cwd: context.cwd,
|
|
3972
|
+
detached,
|
|
3973
|
+
env: {
|
|
3974
|
+
...process.env,
|
|
3975
|
+
PATH: context.pathEnv,
|
|
3976
|
+
PAGER: "cat",
|
|
3977
|
+
GIT_PAGER: "cat",
|
|
3978
|
+
LESS: "-F -X"
|
|
3979
|
+
},
|
|
3980
|
+
stdio: [
|
|
3981
|
+
input.length > 0 ? "pipe" : "ignore",
|
|
3982
|
+
"pipe",
|
|
3983
|
+
"pipe"
|
|
3984
|
+
]
|
|
3985
|
+
});
|
|
3986
|
+
let stdout = "";
|
|
3987
|
+
let stderr = "";
|
|
3988
|
+
let truncated = false;
|
|
3989
|
+
let settled = false;
|
|
3990
|
+
let timedOut = false;
|
|
3991
|
+
const timeout = setTimeout(() => {
|
|
3992
|
+
timedOut = true;
|
|
3993
|
+
killChild(child.pid, detached, "SIGTERM");
|
|
3994
|
+
setTimeout(() => {
|
|
3995
|
+
if (!settled) killChild(child.pid, detached, "SIGKILL");
|
|
3996
|
+
}, 250).unref();
|
|
3997
|
+
}, context.timeoutMs);
|
|
3998
|
+
child.stdout?.on("data", (chunk) => {
|
|
3999
|
+
const next = appendBoundedOutput(stdout, stripUnsafeControlCharacters(chunk.toString("utf8")));
|
|
4000
|
+
stdout = next.output;
|
|
4001
|
+
truncated = truncated || next.truncated;
|
|
4002
|
+
});
|
|
4003
|
+
child.stderr?.on("data", (chunk) => {
|
|
4004
|
+
const next = appendBoundedOutput(stderr, stripUnsafeControlCharacters(chunk.toString("utf8")));
|
|
4005
|
+
stderr = next.output;
|
|
4006
|
+
truncated = truncated || next.truncated;
|
|
4007
|
+
});
|
|
4008
|
+
child.on("error", (error) => {
|
|
4009
|
+
clearTimeout(timeout);
|
|
4010
|
+
settled = true;
|
|
4011
|
+
resolveCommand({
|
|
4012
|
+
stdout,
|
|
4013
|
+
stderr: stderr || `${error.message}\n`,
|
|
4014
|
+
exitCode: 1,
|
|
4015
|
+
timedOut,
|
|
4016
|
+
truncated
|
|
4017
|
+
});
|
|
4018
|
+
});
|
|
4019
|
+
child.on("close", (code, signal) => {
|
|
4020
|
+
clearTimeout(timeout);
|
|
4021
|
+
settled = true;
|
|
4022
|
+
resolveCommand({
|
|
4023
|
+
stdout,
|
|
4024
|
+
stderr,
|
|
4025
|
+
exitCode: timedOut ? 124 : code ?? (signal ? 1 : 0),
|
|
4026
|
+
timedOut,
|
|
4027
|
+
truncated
|
|
4028
|
+
});
|
|
4029
|
+
});
|
|
4030
|
+
if (child.stdin) {
|
|
4031
|
+
child.stdin.on("error", (error) => {
|
|
4032
|
+
if (error.code !== "EPIPE") {
|
|
4033
|
+
const next = appendBoundedOutput(stderr, `${error.message}\n`);
|
|
4034
|
+
stderr = next.output;
|
|
4035
|
+
truncated = truncated || next.truncated;
|
|
4036
|
+
}
|
|
4037
|
+
});
|
|
4038
|
+
child.stdin.end(input);
|
|
4039
|
+
}
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
function killChild(pid, detached, signal) {
|
|
4043
|
+
if (pid === void 0) return;
|
|
4044
|
+
try {
|
|
4045
|
+
process.kill(detached ? -pid : pid, signal);
|
|
4046
|
+
} catch {
|
|
4047
|
+
try {
|
|
4048
|
+
process.kill(pid, signal);
|
|
4049
|
+
} catch {}
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
async function resolveWorkspaceCwd(workspaceRoot, workdir) {
|
|
4053
|
+
const resolvedCwd = resolve(workspaceRoot, workdir);
|
|
4054
|
+
if (!(await stat(resolvedCwd)).isDirectory()) throw new Error(`inspect_command workdir must be a directory inside the workspace: ${workdir}`);
|
|
4055
|
+
const realCwd = await realpath(resolvedCwd);
|
|
4056
|
+
const relativePath = relative(workspaceRoot, realCwd);
|
|
4057
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`inspect_command rejected path outside the workspace: ${workdir}`);
|
|
4058
|
+
return realCwd;
|
|
4059
|
+
}
|
|
4060
|
+
async function findExecutable(name, pathEnv) {
|
|
4061
|
+
for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
|
|
4062
|
+
const executablePath = join(pathEntry, name);
|
|
4063
|
+
try {
|
|
4064
|
+
await access(executablePath, constants.X_OK);
|
|
4065
|
+
return executablePath;
|
|
4066
|
+
} catch {
|
|
4067
|
+
continue;
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
function shouldExecuteEntry(entry, previousExitCode) {
|
|
4072
|
+
switch (entry.operator) {
|
|
4073
|
+
case "start":
|
|
4074
|
+
case ";": return true;
|
|
4075
|
+
case "&&": return previousExitCode === 0;
|
|
4076
|
+
case "||": return previousExitCode !== 0;
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
function getRemainingTimeoutMs(deadlineAt) {
|
|
4080
|
+
return deadlineAt - Date.now();
|
|
4081
|
+
}
|
|
4082
|
+
function formatInspectCommandContent(result) {
|
|
4083
|
+
const sections = [];
|
|
4084
|
+
sections.push(result.stdout.trimEnd() || "(no output)");
|
|
4085
|
+
if (result.stderr.trim()) sections.push(["stderr:", result.stderr.trimEnd()].join("\n"));
|
|
4086
|
+
sections.push([
|
|
4087
|
+
"metadata:",
|
|
4088
|
+
`exit_code: ${result.exitCode}`,
|
|
4089
|
+
`timed_out: ${result.timedOut}`,
|
|
4090
|
+
`truncated: ${result.truncated}`
|
|
4091
|
+
].join("\n"));
|
|
4092
|
+
return sections.join("\n\n");
|
|
4093
|
+
}
|
|
4094
|
+
function appendBoundedOutput(current, next) {
|
|
4095
|
+
const combined = current + next;
|
|
4096
|
+
const byteLimited = Buffer.byteLength(combined, "utf8") > MAX_OUTPUT_BYTES;
|
|
4097
|
+
const lineLimited = combined.split("\n").length > MAX_OUTPUT_LINES;
|
|
4098
|
+
if (!byteLimited && !lineLimited) return {
|
|
4099
|
+
output: combined,
|
|
4100
|
+
truncated: false
|
|
4101
|
+
};
|
|
4102
|
+
let output = combined;
|
|
4103
|
+
if (byteLimited) output = output.slice(0, MAX_OUTPUT_BYTES);
|
|
4104
|
+
if (lineLimited) output = output.split("\n").slice(0, MAX_OUTPUT_LINES).join("\n");
|
|
4105
|
+
return {
|
|
4106
|
+
output: `${output.trimEnd()}\n[truncated]\n`,
|
|
4107
|
+
truncated: true
|
|
4108
|
+
};
|
|
4109
|
+
}
|
|
4110
|
+
function stripUnsafeControlCharacters(output) {
|
|
4111
|
+
return output.replace(/[^\t\n\r -~]/g, "");
|
|
4112
|
+
}
|
|
3289
4113
|
const listFilesTool = defineTool({
|
|
3290
4114
|
name: "list_files",
|
|
3291
4115
|
description: "List files and directories inside a workspace folder.",
|
|
@@ -3401,7 +4225,8 @@ const toolRegistry = {
|
|
|
3401
4225
|
[listFilesTool.name]: listFilesTool,
|
|
3402
4226
|
[grepTool.name]: grepTool,
|
|
3403
4227
|
[findFileTool.name]: findFileTool,
|
|
3404
|
-
[editFileTool.name]: editFileTool
|
|
4228
|
+
[editFileTool.name]: editFileTool,
|
|
4229
|
+
[inspectCommandTool.name]: inspectCommandTool
|
|
3405
4230
|
};
|
|
3406
4231
|
function isToolName(name) {
|
|
3407
4232
|
return name in toolRegistry;
|
|
@@ -3468,6 +4293,15 @@ function summarizeToolArgs(call) {
|
|
|
3468
4293
|
};
|
|
3469
4294
|
}
|
|
3470
4295
|
function summarizeToolResult(result) {
|
|
4296
|
+
if (result.tool === "inspect_command") return {
|
|
4297
|
+
cwd: result.cwd,
|
|
4298
|
+
exitCode: result.exitCode,
|
|
4299
|
+
timedOut: result.timedOut,
|
|
4300
|
+
truncated: result.truncated,
|
|
4301
|
+
decision: result.decision,
|
|
4302
|
+
stdoutLength: result.stdout.length,
|
|
4303
|
+
stderrLength: result.stderr.length
|
|
4304
|
+
};
|
|
3471
4305
|
if (result.tool !== "edit_file") return {};
|
|
3472
4306
|
return {
|
|
3473
4307
|
beforeHash: result.beforeHash,
|
|
@@ -3554,6 +4388,9 @@ function getChatSystemPrompt() {
|
|
|
3554
4388
|
"- When using a tool, output exactly one tool JSON object and no prose, markdown, or additional JSON. After the tool result, either output the next single tool JSON object or a final plain-text answer.",
|
|
3555
4389
|
"- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
|
|
3556
4390
|
"- Use find_file for path or filename lookup. Use grep for text inside files. If grep output mentions another path, treat that mentioned path as content until find_file or read_file confirms it exists.",
|
|
4391
|
+
"- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks.",
|
|
4392
|
+
"- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.",
|
|
4393
|
+
"- inspect_command is not a shell. Unsafe commands, shell expansion, scripts, installs, builds, tests, network access, and file mutation are not available through it.",
|
|
3557
4394
|
"- Use read_file before editing a file so your edit is based on current file content and hash metadata.",
|
|
3558
4395
|
"- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
|
|
3559
4396
|
"- Keep edit_file old_text small but unique. Do not include line labels or grep prefixes in old_text; use exact file text only.",
|
|
@@ -3695,6 +4532,18 @@ function formatToolResultForPrompt(result) {
|
|
|
3695
4532
|
result.diff,
|
|
3696
4533
|
"```"
|
|
3697
4534
|
].join("\n");
|
|
4535
|
+
if (result.tool === "inspect_command") return [
|
|
4536
|
+
`Tool result from ${result.tool} via ${result.command}:`,
|
|
4537
|
+
`cwd: ${result.cwd}`,
|
|
4538
|
+
`exit_code: ${result.exitCode}`,
|
|
4539
|
+
`timed_out: ${result.timedOut}`,
|
|
4540
|
+
`truncated: ${result.truncated}`,
|
|
4541
|
+
`decision: ${result.decision.reason}`,
|
|
4542
|
+
warning ? warning.trimStart() : "",
|
|
4543
|
+
"```",
|
|
4544
|
+
result.content,
|
|
4545
|
+
"```"
|
|
4546
|
+
].filter(Boolean).join("\n");
|
|
3698
4547
|
return [
|
|
3699
4548
|
`Tool result from ${result.tool}${path}${command}:${warning}`,
|
|
3700
4549
|
"```",
|
|
@@ -3704,11 +4553,12 @@ function formatToolResultForPrompt(result) {
|
|
|
3704
4553
|
}
|
|
3705
4554
|
function formatToolCallMessage(call, result) {
|
|
3706
4555
|
switch (call.tool) {
|
|
3707
|
-
case "read_file": return `
|
|
3708
|
-
case "list_files": return `
|
|
3709
|
-
case "grep": return `
|
|
3710
|
-
case "find_file": return `
|
|
3711
|
-
case "edit_file": return `
|
|
4556
|
+
case "read_file": return `read_file: ${call.args.path}`;
|
|
4557
|
+
case "list_files": return `list_files: ${call.args.path}${call.args.recursive ? " (recursive)" : ""}`;
|
|
4558
|
+
case "grep": return `grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
|
|
4559
|
+
case "find_file": return `find_file: ${call.args.query} in ${call.args.path}`;
|
|
4560
|
+
case "edit_file": return `edit_file: ${call.args.path}${formatEditFileChangeSummary(result)}`;
|
|
4561
|
+
case "inspect_command": return `inspect_command: ${call.args.command}`;
|
|
3712
4562
|
}
|
|
3713
4563
|
}
|
|
3714
4564
|
function formatEditFileChangeSummary(result) {
|
|
@@ -3739,7 +4589,7 @@ function renderRuntimeEvent(event) {
|
|
|
3739
4589
|
switch (event.type) {
|
|
3740
4590
|
case "message": return [event.role === "assistant" ? agentMessage(event.text, event.meta) : systemMessage(event.text)];
|
|
3741
4591
|
case "tool_call": return [systemMessage(event.label)];
|
|
3742
|
-
case "knowledge_status": return [systemMessage(`KB status: ${
|
|
4592
|
+
case "knowledge_status": return [systemMessage(`KB status: ${formatKnowledgePathStatus$1(event.status)}${formatKbPathSource(event.status)}`)];
|
|
3743
4593
|
case "choice": return [modalMessage({
|
|
3744
4594
|
tone: event.tone,
|
|
3745
4595
|
title: event.title,
|
|
@@ -3749,6 +4599,9 @@ function renderRuntimeEvent(event) {
|
|
|
3749
4599
|
case "status": return [];
|
|
3750
4600
|
}
|
|
3751
4601
|
}
|
|
4602
|
+
function formatKbPathSource(status) {
|
|
4603
|
+
return status.kbPathSource === "env" ? " (custom)" : "";
|
|
4604
|
+
}
|
|
3752
4605
|
//#endregion
|
|
3753
4606
|
//#region src/tui/terminal.ts
|
|
3754
4607
|
function enterAlternateScreen(terminal) {
|
|
@@ -4135,10 +4988,11 @@ kbCommand.command("status").description("show project knowledge base status").ac
|
|
|
4135
4988
|
const status = await ui.spinner("Checking knowledge base...", () => getKnowledgeStatus(context.workspaceRoot));
|
|
4136
4989
|
console.log(ui.heading("KB status"));
|
|
4137
4990
|
console.log(`${ui.label("workspace")}: ${status.workspaceRoot}`);
|
|
4138
|
-
console.log(`${ui.label("knowledge folder")}: ${
|
|
4991
|
+
console.log(`${ui.label("knowledge folder")}: ${formatKnowledgePathStatus(status)} ${ui.label(`(${status.kbPathSource})`)}`);
|
|
4139
4992
|
console.log(`${ui.label("local cache folder")}: ${formatPathStatus(status.cachePath, status.cacheExists, status.cacheIsDirectory)} ${ui.label(`(${status.cachePathSource})`)}`);
|
|
4140
4993
|
if (!status.kbExists) console.log(`${ui.label("state")}: ${ui.warn("no knowledge base found yet")}`);
|
|
4141
4994
|
else if (!status.kbIsDirectory) console.log(`${ui.label("state")}: ${ui.error("knowledge base path is not a folder")}`);
|
|
4995
|
+
else if (status.kbContentState !== "ready") console.log(`${ui.label("state")}: ${ui.label("knowledge base folder is empty")}`);
|
|
4142
4996
|
else console.log(`${ui.label("state")}: ${ui.ok("knowledge base found")}`);
|
|
4143
4997
|
});
|
|
4144
4998
|
await program.parseAsync();
|
|
@@ -4190,6 +5044,12 @@ function formatPathStatus(path, exists, isDirectory) {
|
|
|
4190
5044
|
if (!isDirectory) return `${path} ${ui.error("[not a folder]")}`;
|
|
4191
5045
|
return `${path} ${ui.ok("[ok]")}`;
|
|
4192
5046
|
}
|
|
5047
|
+
function formatKnowledgePathStatus(status) {
|
|
5048
|
+
if (!status.kbExists) return `${status.kbPath} ${ui.warn("[missing]")}`;
|
|
5049
|
+
if (!status.kbIsDirectory) return `${status.kbPath} ${ui.error("[not a folder]")}`;
|
|
5050
|
+
if (status.kbContentState !== "ready") return `${status.kbPath} ${ui.label("[empty]")}`;
|
|
5051
|
+
return `${status.kbPath} ${ui.ok("[ok]")}`;
|
|
5052
|
+
}
|
|
4193
5053
|
//#endregion
|
|
4194
5054
|
export {};
|
|
4195
5055
|
|