topchester-ai 0.5.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 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 {
@@ -1983,8 +1983,12 @@ function renderSystemMessage(lines) {
1983
1983
  return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
1984
1984
  }
1985
1985
  function formatSystemBodyLine(line) {
1986
+ if (isToolCallLine(line)) return ui.muted(line);
1986
1987
  return line.replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary));
1987
1988
  }
1989
+ function isToolCallLine(line) {
1990
+ return /^(read_file|list_files|grep|find_file|edit_file|inspect_command): /u.test(line);
1991
+ }
1988
1992
  function getPrefix(kind) {
1989
1993
  switch (kind) {
1990
1994
  case "agent": return " ";
@@ -3053,7 +3057,7 @@ async function collectWorkspaceFilesWithNode(workspaceRoot, startPath) {
3053
3057
  return files;
3054
3058
  }
3055
3059
  async function createRipgrepCollector(pathEnv, relativeStartPath) {
3056
- const command = await findExecutable$1("rg", pathEnv);
3060
+ const command = await findExecutable$2("rg", pathEnv);
3057
3061
  if (!command) return;
3058
3062
  return {
3059
3063
  name: "rg",
@@ -3069,8 +3073,8 @@ async function createRipgrepCollector(pathEnv, relativeStartPath) {
3069
3073
  };
3070
3074
  }
3071
3075
  async function createFdCollector(pathEnv, relativeStartPath) {
3072
- const fdCommand = await findExecutable$1("fd", pathEnv);
3073
- const fdfindCommand = fdCommand ? void 0 : await findExecutable$1("fdfind", pathEnv);
3076
+ const fdCommand = await findExecutable$2("fd", pathEnv);
3077
+ const fdfindCommand = fdCommand ? void 0 : await findExecutable$2("fdfind", pathEnv);
3074
3078
  const command = fdCommand ?? fdfindCommand;
3075
3079
  if (!command) return;
3076
3080
  return {
@@ -3090,7 +3094,7 @@ async function createFdCollector(pathEnv, relativeStartPath) {
3090
3094
  };
3091
3095
  }
3092
3096
  async function createFindCollector(pathEnv, relativeStartPath) {
3093
- const command = await findExecutable$1("find", pathEnv);
3097
+ const command = await findExecutable$2("find", pathEnv);
3094
3098
  if (!command) return;
3095
3099
  return {
3096
3100
  name: "find",
@@ -3185,7 +3189,7 @@ function resolveWorkspaceScopedPath$2(workspaceRoot, path) {
3185
3189
  relativePath: relativePath || "."
3186
3190
  };
3187
3191
  }
3188
- async function findExecutable$1(name, pathEnv) {
3192
+ async function findExecutable$2(name, pathEnv) {
3189
3193
  for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
3190
3194
  const executablePath = join(pathEntry, name);
3191
3195
  try {
@@ -3309,14 +3313,14 @@ function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
3309
3313
  }
3310
3314
  async function findSearchExecutable(pathEnv = process.env.PATH ?? "") {
3311
3315
  for (const name of ["rg", "grep"]) {
3312
- const executablePath = await findExecutable(name, pathEnv);
3316
+ const executablePath = await findExecutable$1(name, pathEnv);
3313
3317
  if (executablePath) return {
3314
3318
  name,
3315
3319
  path: executablePath
3316
3320
  };
3317
3321
  }
3318
3322
  }
3319
- async function findExecutable(name, pathEnv) {
3323
+ async function findExecutable$1(name, pathEnv) {
3320
3324
  for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
3321
3325
  const executablePath = join(pathEntry, name);
3322
3326
  try {
@@ -3354,6 +3358,758 @@ function truncateToolOutput(output) {
3354
3358
  function isRecord$1(value) {
3355
3359
  return typeof value === "object" && value !== null;
3356
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
+ }
3357
4113
  const listFilesTool = defineTool({
3358
4114
  name: "list_files",
3359
4115
  description: "List files and directories inside a workspace folder.",
@@ -3469,7 +4225,8 @@ const toolRegistry = {
3469
4225
  [listFilesTool.name]: listFilesTool,
3470
4226
  [grepTool.name]: grepTool,
3471
4227
  [findFileTool.name]: findFileTool,
3472
- [editFileTool.name]: editFileTool
4228
+ [editFileTool.name]: editFileTool,
4229
+ [inspectCommandTool.name]: inspectCommandTool
3473
4230
  };
3474
4231
  function isToolName(name) {
3475
4232
  return name in toolRegistry;
@@ -3536,6 +4293,15 @@ function summarizeToolArgs(call) {
3536
4293
  };
3537
4294
  }
3538
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
+ };
3539
4305
  if (result.tool !== "edit_file") return {};
3540
4306
  return {
3541
4307
  beforeHash: result.beforeHash,
@@ -3622,6 +4388,9 @@ function getChatSystemPrompt() {
3622
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.",
3623
4389
  "- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
3624
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.",
3625
4394
  "- Use read_file before editing a file so your edit is based on current file content and hash metadata.",
3626
4395
  "- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
3627
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.",
@@ -3763,6 +4532,18 @@ function formatToolResultForPrompt(result) {
3763
4532
  result.diff,
3764
4533
  "```"
3765
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");
3766
4547
  return [
3767
4548
  `Tool result from ${result.tool}${path}${command}:${warning}`,
3768
4549
  "```",
@@ -3772,11 +4553,12 @@ function formatToolResultForPrompt(result) {
3772
4553
  }
3773
4554
  function formatToolCallMessage(call, result) {
3774
4555
  switch (call.tool) {
3775
- case "read_file": return `Tool read_file: ${call.args.path}`;
3776
- case "list_files": return `Tool list_files: ${call.args.path}${call.args.recursive ? " (recursive)" : ""}`;
3777
- case "grep": return `Tool grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
3778
- case "find_file": return `Tool find_file: ${call.args.query} in ${call.args.path}`;
3779
- case "edit_file": return `Tool edit_file: ${call.args.path}${formatEditFileChangeSummary(result)}`;
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}`;
3780
4562
  }
3781
4563
  }
3782
4564
  function formatEditFileChangeSummary(result) {