@cortexkit/aft-pi 0.33.0 → 0.34.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/index.js CHANGED
@@ -13152,26 +13152,28 @@ function getManualInstallHint() {
13152
13152
  }
13153
13153
  async function ensureOnnxRuntime(storageDir) {
13154
13154
  const info = getPlatformInfo();
13155
- const ortDir = join6(storageDir, "onnxruntime", ORT_VERSION);
13156
- const libPath = join6(ortDir, info?.libName ?? "libonnxruntime.dylib");
13155
+ const ortVersionDir = join6(storageDir, "onnxruntime", ORT_VERSION);
13156
+ const libName = info?.libName ?? "libonnxruntime.dylib";
13157
+ const resolvedOrtDir = resolveCachedOnnxRuntimeDir(ortVersionDir, libName);
13158
+ const libPath = join6(resolvedOrtDir, libName);
13157
13159
  if (existsSync5(libPath)) {
13158
- const meta = readOnnxInstalledMeta(ortDir);
13160
+ const meta = readOnnxInstalledMeta(ortVersionDir);
13159
13161
  if (meta?.sha256) {
13160
13162
  try {
13161
13163
  const currentHash = sha256File(libPath);
13162
13164
  if (currentHash !== meta.sha256) {
13163
- error(`ONNX Runtime at ${ortDir}: TOFU sha256 mismatch — refusing to use ` + `tampered binary. Recorded ${meta.sha256}, current ${currentHash}. ` + `Run \`aft doctor --clear\` to re-download from scratch.`);
13165
+ error(`ONNX Runtime at ${resolvedOrtDir}: TOFU sha256 mismatch — refusing to use ` + `tampered binary. Recorded ${meta.sha256}, current ${currentHash}. ` + `Run \`aft doctor --clear\` to re-download from scratch.`);
13164
13166
  } else {
13165
- log(`ONNX Runtime found at ${ortDir} (TOFU verified)`);
13166
- return ortDir;
13167
+ log(`ONNX Runtime found at ${resolvedOrtDir} (TOFU verified)`);
13168
+ return resolvedOrtDir;
13167
13169
  }
13168
13170
  } catch (err) {
13169
- warn(`Could not verify ONNX Runtime hash at ${ortDir}: ${err}`);
13170
- return ortDir;
13171
+ warn(`Could not verify ONNX Runtime hash at ${resolvedOrtDir}: ${err}`);
13172
+ return resolvedOrtDir;
13171
13173
  }
13172
13174
  } else {
13173
- log(`ONNX Runtime found at ${ortDir} (no recorded hash, accepting)`);
13174
- return ortDir;
13175
+ log(`ONNX Runtime found at ${resolvedOrtDir} (no recorded hash, accepting)`);
13176
+ return resolvedOrtDir;
13175
13177
  }
13176
13178
  }
13177
13179
  const systemPath = findSystemOnnxRuntime(info?.libName);
@@ -13192,8 +13194,8 @@ async function ensureOnnxRuntime(storageDir) {
13192
13194
  return null;
13193
13195
  }
13194
13196
  try {
13195
- cleanupIncompleteTargetIfUnowned(ortDir);
13196
- return await downloadOnnxRuntime(info, ortDir);
13197
+ cleanupIncompleteTargetIfUnowned(ortVersionDir);
13198
+ return await downloadOnnxRuntime(info, ortVersionDir);
13197
13199
  } finally {
13198
13200
  releaseLock(lockPath);
13199
13201
  }
@@ -13211,11 +13213,16 @@ function cleanupAbandonedStagingDirs(onnxBaseDir) {
13211
13213
  let abandoned = false;
13212
13214
  if (Number.isFinite(pid) && pid > 0) {
13213
13215
  if (process.platform === "win32") {
13214
- try {
13215
- const ageMs = Date.now() - statSync2(stagingDir).mtimeMs;
13216
- abandoned = ageMs > STALE_LOCK_MS;
13217
- } catch {
13216
+ const ownerAlive = isProcessAlive(pid);
13217
+ if (!ownerAlive) {
13218
13218
  abandoned = true;
13219
+ } else {
13220
+ try {
13221
+ const ageMs = Date.now() - statSync2(stagingDir).mtimeMs;
13222
+ abandoned = ageMs > STALE_LOCK_MS;
13223
+ } catch {
13224
+ abandoned = true;
13225
+ }
13219
13226
  }
13220
13227
  } else {
13221
13228
  abandoned = !isProcessAlive(pid);
@@ -13249,17 +13256,32 @@ var REQUIRED_ORT_MIN_MINOR = 20;
13249
13256
  var INVALID_ORT_VERSION = "<invalid>";
13250
13257
  function parseOnnxVersionFromPath(value) {
13251
13258
  const name = basename(value);
13252
- const semverish = name.match(/\.(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)(?:\.dylib)?$/);
13259
+ const semverish = name.match(/(?:^|[._-])(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)(?:\.(?:dylib|dll))?$/);
13253
13260
  if (semverish)
13254
13261
  return semverish[1].split(/[-+]/, 1)[0];
13255
13262
  return /\d+\.\d+\.\d+/.test(name) ? INVALID_ORT_VERSION : null;
13256
13263
  }
13264
+ function parseOnnxVersionFromDirectoryPath(value) {
13265
+ const parts = value.split(/[\\/]+/).filter(Boolean).reverse();
13266
+ for (const part of parts) {
13267
+ const version = parseOnnxVersionFromPath(part);
13268
+ if (version)
13269
+ return version;
13270
+ }
13271
+ return null;
13272
+ }
13273
+ function isPathInsideRoot(root, candidate) {
13274
+ const rel = relative(root, candidate);
13275
+ return rel === "" || !rel.startsWith("../") && !rel.startsWith("..\\") && rel !== ".." && !isAbsolute(rel) && !win32.isAbsolute(rel);
13276
+ }
13257
13277
  function detectOnnxVersion(libDir, libName) {
13258
13278
  try {
13259
13279
  const entries = readdirSync(libDir);
13260
13280
  const barePrefix = libName.replace(/\.(so|dylib|dll)$/, "");
13281
+ const expectedPrefix = process.platform === "win32" ? barePrefix.toLowerCase() : barePrefix;
13261
13282
  for (const entry of entries) {
13262
- if (!entry.startsWith(barePrefix))
13283
+ const comparable = process.platform === "win32" ? entry.toLowerCase() : entry;
13284
+ if (!comparable.startsWith(expectedPrefix))
13263
13285
  continue;
13264
13286
  const version = parseOnnxVersionFromPath(entry);
13265
13287
  if (version)
@@ -13269,7 +13291,7 @@ function detectOnnxVersion(libDir, libName) {
13269
13291
  if (existsSync5(base)) {
13270
13292
  try {
13271
13293
  const real = realpathSync(base);
13272
- const version = parseOnnxVersionFromPath(real);
13294
+ const version = parseOnnxVersionFromPath(real) ?? parseOnnxVersionFromDirectoryPath(real);
13273
13295
  if (version)
13274
13296
  return version;
13275
13297
  } catch {}
@@ -13280,6 +13302,7 @@ function detectOnnxVersion(libDir, libName) {
13280
13302
  return version;
13281
13303
  } catch {}
13282
13304
  }
13305
+ return parseOnnxVersionFromDirectoryPath(libDir);
13283
13306
  } catch {}
13284
13307
  return null;
13285
13308
  }
@@ -13315,6 +13338,14 @@ function directoryContainsLibrary(dir, libName) {
13315
13338
  return false;
13316
13339
  }
13317
13340
  }
13341
+ function resolveCachedOnnxRuntimeDir(ortVersionDir, libName) {
13342
+ if (existsSync5(join6(ortVersionDir, libName)))
13343
+ return ortVersionDir;
13344
+ const libSubdir = join6(ortVersionDir, "lib");
13345
+ if (existsSync5(join6(libSubdir, libName)))
13346
+ return libSubdir;
13347
+ return ortVersionDir;
13348
+ }
13318
13349
  function findSystemOnnxRuntime(libName) {
13319
13350
  if (!libName)
13320
13351
  return null;
@@ -13360,6 +13391,7 @@ function findSystemOnnxRuntime(libName) {
13360
13391
  seen.add(key);
13361
13392
  return true;
13362
13393
  });
13394
+ const unknownVersionPaths = [];
13363
13395
  for (const dir of uniquePaths) {
13364
13396
  const libPath = join6(dir, libName);
13365
13397
  if (process.platform === "win32") {
@@ -13368,23 +13400,18 @@ function findSystemOnnxRuntime(libName) {
13368
13400
  } else if (!existsSync5(libPath)) {
13369
13401
  continue;
13370
13402
  }
13371
- let skipVersionCheck = false;
13372
- if (process.platform === "win32") {
13373
- const dirLower = dir.toLowerCase();
13374
- const isCommonPath = dirLower.includes("program files") || dirLower.includes("onnxruntime");
13375
- if (!isCommonPath)
13376
- skipVersionCheck = true;
13377
- }
13378
- if (!skipVersionCheck) {
13379
- const version = detectOnnxVersion(dir, libName);
13380
- if (version && !isOnnxVersionCompatible(version)) {
13381
- warn(`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` + `v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`);
13382
- continue;
13383
- }
13403
+ const version = detectOnnxVersion(dir, libName);
13404
+ if (!version) {
13405
+ unknownVersionPaths.push(dir);
13406
+ continue;
13407
+ }
13408
+ if (!isOnnxVersionCompatible(version)) {
13409
+ warn(`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` + `v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`);
13410
+ continue;
13384
13411
  }
13385
13412
  return dir;
13386
13413
  }
13387
- return null;
13414
+ return unknownVersionPaths[0] ?? null;
13388
13415
  }
13389
13416
  async function downloadFileWithCap(url, destPath) {
13390
13417
  const controller = new AbortController;
@@ -13438,13 +13465,13 @@ function validateExtractedTree(stagingRoot) {
13438
13465
  const linkTarget = readlinkSync(fullPath);
13439
13466
  const resolvedTarget = resolve(dirname3(fullPath), linkTarget);
13440
13467
  const rel2 = relative(realRoot, resolvedTarget);
13441
- if (rel2.startsWith("..") || process.platform !== "win32" && rel2.startsWith("/")) {
13468
+ if (rel2.startsWith("..") || isAbsolute(rel2) || win32.isAbsolute(rel2)) {
13442
13469
  throw new Error(`extracted symlink ${fullPath} points outside staging root: ${linkTarget}`);
13443
13470
  }
13444
13471
  continue;
13445
13472
  }
13446
13473
  const rel = relative(realRoot, fullPath);
13447
- if (rel.startsWith("..") || process.platform !== "win32" && rel.startsWith("/")) {
13474
+ if (rel.startsWith("..") || isAbsolute(rel) || win32.isAbsolute(rel)) {
13448
13475
  throw new Error(`extracted entry ${fullPath} escapes staging root`);
13449
13476
  }
13450
13477
  if (lst.isDirectory()) {
@@ -13547,11 +13574,23 @@ function copyOnnxLibraries(info, extractedDir, targetDir, realFiles, symlinks, c
13547
13574
  log(`ORT extract: failed to copy optional ${libFile}: ${copyErr}`);
13548
13575
  }
13549
13576
  }
13577
+ const targetRoot = realpathSync(targetDir);
13550
13578
  for (const link of symlinks) {
13551
13579
  const dst = join6(targetDir, link.name);
13552
13580
  try {
13553
13581
  unlinkSync3(dst);
13554
13582
  } catch {}
13583
+ const dstForContainment = join6(targetRoot, link.name);
13584
+ const resolvedTarget = resolve(dirname3(dstForContainment), link.target);
13585
+ if (!isPathInsideRoot(targetRoot, resolvedTarget)) {
13586
+ const message = `ONNX Runtime symlink ${link.name} points outside install dir: ${link.target}`;
13587
+ if (requiredLibs.has(link.name)) {
13588
+ rmSync2(targetDir, { recursive: true, force: true });
13589
+ throw new Error(message);
13590
+ }
13591
+ log(`ORT extract: skipping optional symlink ${link.name}: ${message}`);
13592
+ continue;
13593
+ }
13555
13594
  try {
13556
13595
  symlinkSync(link.target, dst);
13557
13596
  } catch (symlinkErr) {
@@ -13654,9 +13693,8 @@ ${new Date().toISOString()}
13654
13693
  }
13655
13694
  const age = Date.now() - lockMtimeMs;
13656
13695
  const ageWithinFresh = Math.abs(age) < STALE_LOCK_MS;
13657
- const skipLiveness = process.platform === "win32";
13658
- const ownerAlive = !skipLiveness && owningPid !== null && isProcessAlive(owningPid);
13659
- if (skipLiveness ? ageWithinFresh : ownerAlive && ageWithinFresh) {
13696
+ const ownerAlive = owningPid !== null && isProcessAlive(owningPid);
13697
+ if (ownerAlive && ageWithinFresh) {
13660
13698
  return false;
13661
13699
  }
13662
13700
  log(`[onnx] reclaiming install lock (owner_pid=${owningPid ?? "unknown"}, alive=${ownerAlive}, age_ms=${age})`);
@@ -13697,7 +13735,31 @@ function releaseLock(lockPath) {
13697
13735
  warn(`[onnx] unexpected error releasing lock ${lockPath}: ${err}`);
13698
13736
  }
13699
13737
  }
13738
+ function tasklistPidFromCsvLine(line) {
13739
+ const quoted = line.match(/"([^"]*)"/g);
13740
+ if (quoted && quoted.length >= 2)
13741
+ return quoted[1].slice(1, -1);
13742
+ const cells = line.split(",").map((cell) => cell.trim().replace(/^"|"$/g, ""));
13743
+ return cells[1] ?? null;
13744
+ }
13745
+ function isWindowsProcessAlive(pid) {
13746
+ if (!Number.isInteger(pid) || pid <= 0)
13747
+ return false;
13748
+ try {
13749
+ const output = execFileSync("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
13750
+ encoding: "utf8",
13751
+ timeout: 2000,
13752
+ windowsHide: true
13753
+ });
13754
+ const expected = String(pid);
13755
+ return output.split(/\r?\n/).some((line) => tasklistPidFromCsvLine(line) === expected);
13756
+ } catch {
13757
+ return false;
13758
+ }
13759
+ }
13700
13760
  function isProcessAlive(pid) {
13761
+ if (process.platform === "win32")
13762
+ return isWindowsProcessAlive(pid);
13701
13763
  try {
13702
13764
  process.kill(pid, 0);
13703
13765
  return true;
@@ -14632,7 +14694,7 @@ import {
14632
14694
  // package.json
14633
14695
  var package_default = {
14634
14696
  name: "@cortexkit/aft-pi",
14635
- version: "0.33.0",
14697
+ version: "0.34.0",
14636
14698
  type: "module",
14637
14699
  description: "Pi coding agent extension for Agent File Tools (AFT) — tree-sitter and LSP-powered code analysis",
14638
14700
  main: "dist/index.js",
@@ -14651,10 +14713,11 @@ var package_default = {
14651
14713
  typecheck: "tsc --noEmit",
14652
14714
  test: "bun test src/__tests__/",
14653
14715
  lint: "biome check src",
14654
- prepublishOnly: "bun run build"
14716
+ prepublishOnly: "bun run build",
14717
+ "test:unit": "bun test src/__tests__ --path-ignore-patterns 'src/__tests__/e2e/**'"
14655
14718
  },
14656
14719
  dependencies: {
14657
- "@cortexkit/aft-bridge": "0.33.0",
14720
+ "@cortexkit/aft-bridge": "0.34.0",
14658
14721
  "@xterm/headless": "^5.5.0",
14659
14722
  "comment-json": "^5.0.0",
14660
14723
  diff: "^8.0.4",
@@ -14662,12 +14725,12 @@ var package_default = {
14662
14725
  zod: "^4.1.8"
14663
14726
  },
14664
14727
  optionalDependencies: {
14665
- "@cortexkit/aft-darwin-arm64": "0.33.0",
14666
- "@cortexkit/aft-darwin-x64": "0.33.0",
14667
- "@cortexkit/aft-linux-arm64": "0.33.0",
14668
- "@cortexkit/aft-linux-x64": "0.33.0",
14669
- "@cortexkit/aft-win32-arm64": "0.33.0",
14670
- "@cortexkit/aft-win32-x64": "0.33.0"
14728
+ "@cortexkit/aft-darwin-arm64": "0.34.0",
14729
+ "@cortexkit/aft-darwin-x64": "0.34.0",
14730
+ "@cortexkit/aft-linux-arm64": "0.34.0",
14731
+ "@cortexkit/aft-linux-x64": "0.34.0",
14732
+ "@cortexkit/aft-win32-arm64": "0.34.0",
14733
+ "@cortexkit/aft-win32-x64": "0.34.0"
14671
14734
  },
14672
14735
  devDependencies: {
14673
14736
  "@earendil-works/pi-coding-agent": "*",
@@ -28851,6 +28914,8 @@ function date4(params) {
28851
28914
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
28852
28915
  config(en_default());
28853
28916
  // src/config.ts
28917
+ var FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 8000;
28918
+ var FOREGROUND_WAIT_WINDOW_MIN_MS = 5000;
28854
28919
  function resolveBashConfig(config2) {
28855
28920
  const top = config2.bash;
28856
28921
  const legacy = config2.experimental?.bash;
@@ -28858,13 +28923,16 @@ function resolveBashConfig(config2) {
28858
28923
  const surfaceDefaultEnabled = surface !== "minimal";
28859
28924
  const reminderEnabled = (typeof top === "object" && top !== null ? top.long_running_reminder_enabled : undefined) ?? legacy?.long_running_reminder_enabled;
28860
28925
  const reminderInterval = (typeof top === "object" && top !== null ? top.long_running_reminder_interval_ms : undefined) ?? legacy?.long_running_reminder_interval_ms;
28926
+ const rawForegroundWait = typeof top === "object" && top !== null ? top.foreground_wait_window_ms : undefined;
28927
+ const foregroundWaitWindowMs = Math.max(FOREGROUND_WAIT_WINDOW_MIN_MS, rawForegroundWait ?? FOREGROUND_WAIT_WINDOW_DEFAULT_MS);
28861
28928
  const base = {
28862
28929
  enabled: false,
28863
28930
  rewrite: false,
28864
28931
  compress: false,
28865
28932
  background: false,
28866
28933
  long_running_reminder_enabled: reminderEnabled,
28867
- long_running_reminder_interval_ms: reminderInterval
28934
+ long_running_reminder_interval_ms: reminderInterval,
28935
+ foreground_wait_window_ms: foregroundWaitWindowMs
28868
28936
  };
28869
28937
  if (top === false)
28870
28938
  return base;
@@ -28918,13 +28986,15 @@ var CheckerEnum = exports_external.enum([
28918
28986
  "staticcheck",
28919
28987
  "none"
28920
28988
  ]);
28989
+ var ConfigureWarningsDeliveryEnum = exports_external.enum(["toast", "log", "chat"]);
28921
28990
  var SemanticConfigSchema = exports_external.object({
28922
28991
  backend: exports_external.enum(["fastembed", "openai_compatible", "ollama"]).optional(),
28923
28992
  model: exports_external.string().trim().min(1).optional(),
28924
28993
  base_url: exports_external.string().trim().min(1).optional(),
28925
28994
  api_key_env: exports_external.string().trim().min(1).optional(),
28926
28995
  timeout_ms: exports_external.number().int().positive().optional(),
28927
- max_batch_size: exports_external.number().int().positive().optional()
28996
+ max_batch_size: exports_external.number().int().positive().optional(),
28997
+ max_files: exports_external.number().int().positive().optional()
28928
28998
  });
28929
28999
  var LspExtensionSchema = exports_external.string().trim().min(1).refine((value) => value.replace(/^\.+/, "").length > 0, {
28930
29000
  message: "Extension must include characters other than leading dots"
@@ -28965,7 +29035,8 @@ var BashFeaturesSchema = exports_external.object({
28965
29035
  compress: exports_external.boolean().optional(),
28966
29036
  background: exports_external.boolean().optional(),
28967
29037
  long_running_reminder_enabled: exports_external.boolean().optional(),
28968
- long_running_reminder_interval_ms: exports_external.number().int().positive().optional()
29038
+ long_running_reminder_interval_ms: exports_external.number().int().positive().optional(),
29039
+ foreground_wait_window_ms: exports_external.number().int().positive().optional()
28969
29040
  });
28970
29041
  var BashConfigSchema = exports_external.union([exports_external.boolean(), BashFeaturesSchema]);
28971
29042
  var InspectConfigSchema = exports_external.object({
@@ -28993,6 +29064,7 @@ var AftConfigSchema = exports_external.object({
28993
29064
  validate_on_edit: exports_external.enum(["syntax", "full"]).optional(),
28994
29065
  formatter: exports_external.record(exports_external.string(), FormatterEnum).optional(),
28995
29066
  checker: exports_external.record(exports_external.string(), CheckerEnum).optional(),
29067
+ configure_warnings_delivery: ConfigureWarningsDeliveryEnum.optional(),
28996
29068
  tool_surface: exports_external.enum(["minimal", "recommended", "all"]).optional(),
28997
29069
  disabled_tools: exports_external.array(exports_external.string()).optional(),
28998
29070
  restrict_to_project_root: exports_external.boolean().optional(),
@@ -29284,6 +29356,8 @@ function mergeSemanticConfig(base, override) {
29284
29356
  projectSafe.timeout_ms = override.timeout_ms;
29285
29357
  if (override?.max_batch_size !== undefined)
29286
29358
  projectSafe.max_batch_size = override.max_batch_size;
29359
+ if (override?.max_files !== undefined)
29360
+ projectSafe.max_files = override.max_files;
29287
29361
  const semantic = { ...base, ...projectSafe };
29288
29362
  if (Object.values(semantic).every((v) => v === undefined))
29289
29363
  return;
@@ -29381,6 +29455,7 @@ var PROJECT_SAFE_TOP_LEVEL_FIELDS = new Set([
29381
29455
  "tool_surface",
29382
29456
  "format_on_edit",
29383
29457
  "validate_on_edit",
29458
+ "configure_warnings_delivery",
29384
29459
  "search_index",
29385
29460
  "semantic_search",
29386
29461
  "inspect",
@@ -31368,1757 +31443,1806 @@ function registerShutdownCleanup(fn) {
31368
31443
 
31369
31444
  // src/tools/ast.ts
31370
31445
  import { StringEnum } from "@earendil-works/pi-ai";
31371
- import { Type as Type2 } from "typebox";
31446
+ import { Type as Type3 } from "typebox";
31372
31447
 
31373
- // src/tools/render-helpers.ts
31448
+ // src/tools/hoisted.ts
31449
+ import { stat } from "node:fs/promises";
31374
31450
  import { homedir as homedir8 } from "node:os";
31375
- import { renderDiff } from "@earendil-works/pi-coding-agent";
31451
+ import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve3, sep } from "node:path";
31452
+ import {
31453
+ renderDiff
31454
+ } from "@earendil-works/pi-coding-agent";
31376
31455
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
31377
- function reuseText(last) {
31378
- return last instanceof Text ? last : new Text("", 0, 0);
31379
- }
31380
- function reuseContainer(last) {
31381
- return last instanceof Container ? last : new Container;
31456
+ import { Type as Type2 } from "typebox";
31457
+
31458
+ // src/tools/diff-format.ts
31459
+ import { diffLines } from "diff";
31460
+ var DEFAULT_CONTEXT_LINES = 4;
31461
+ function formatDiffForPi(oldContent, newContent, contextLines = DEFAULT_CONTEXT_LINES) {
31462
+ const parts = diffLines(oldContent, newContent);
31463
+ const output = [];
31464
+ const oldLines = oldContent.split(`
31465
+ `);
31466
+ const newLines = newContent.split(`
31467
+ `);
31468
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
31469
+ const lineNumWidth = String(maxLineNum).length;
31470
+ const pad = (n) => String(n).padStart(lineNumWidth, " ");
31471
+ const blank = " ".repeat(lineNumWidth);
31472
+ let oldLineNum = 1;
31473
+ let newLineNum = 1;
31474
+ let lastWasChange = false;
31475
+ let firstChangedLine;
31476
+ for (let i = 0;i < parts.length; i++) {
31477
+ const part = parts[i];
31478
+ const raw = part.value.split(`
31479
+ `);
31480
+ if (raw[raw.length - 1] === "")
31481
+ raw.pop();
31482
+ if (part.added || part.removed) {
31483
+ if (firstChangedLine === undefined)
31484
+ firstChangedLine = newLineNum;
31485
+ for (const line of raw) {
31486
+ if (part.added) {
31487
+ output.push(`+${pad(newLineNum)} ${line}`);
31488
+ newLineNum++;
31489
+ } else {
31490
+ output.push(`-${pad(oldLineNum)} ${line}`);
31491
+ oldLineNum++;
31492
+ }
31493
+ }
31494
+ lastWasChange = true;
31495
+ continue;
31496
+ }
31497
+ const nextIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
31498
+ const hasLeading = lastWasChange;
31499
+ const hasTrailing = nextIsChange;
31500
+ if (hasLeading && hasTrailing) {
31501
+ if (raw.length <= contextLines * 2) {
31502
+ for (const line of raw) {
31503
+ output.push(` ${pad(oldLineNum)} ${line}`);
31504
+ oldLineNum++;
31505
+ newLineNum++;
31506
+ }
31507
+ } else {
31508
+ for (const line of raw.slice(0, contextLines)) {
31509
+ output.push(` ${pad(oldLineNum)} ${line}`);
31510
+ oldLineNum++;
31511
+ newLineNum++;
31512
+ }
31513
+ const skipped = raw.length - contextLines * 2;
31514
+ output.push(` ${blank} ...`);
31515
+ oldLineNum += skipped;
31516
+ newLineNum += skipped;
31517
+ for (const line of raw.slice(raw.length - contextLines)) {
31518
+ output.push(` ${pad(oldLineNum)} ${line}`);
31519
+ oldLineNum++;
31520
+ newLineNum++;
31521
+ }
31522
+ }
31523
+ } else if (hasLeading) {
31524
+ const shown = raw.slice(0, contextLines);
31525
+ for (const line of shown) {
31526
+ output.push(` ${pad(oldLineNum)} ${line}`);
31527
+ oldLineNum++;
31528
+ newLineNum++;
31529
+ }
31530
+ const skipped = raw.length - shown.length;
31531
+ if (skipped > 0) {
31532
+ output.push(` ${blank} ...`);
31533
+ oldLineNum += skipped;
31534
+ newLineNum += skipped;
31535
+ }
31536
+ } else if (hasTrailing) {
31537
+ const shownCount = Math.min(contextLines, raw.length);
31538
+ const shown = raw.slice(raw.length - shownCount);
31539
+ const skipped = raw.length - shown.length;
31540
+ if (skipped > 0) {
31541
+ output.push(` ${blank} ...`);
31542
+ oldLineNum += skipped;
31543
+ newLineNum += skipped;
31544
+ }
31545
+ for (const line of shown) {
31546
+ output.push(` ${pad(oldLineNum)} ${line}`);
31547
+ oldLineNum++;
31548
+ newLineNum++;
31549
+ }
31550
+ } else {
31551
+ oldLineNum += raw.length;
31552
+ newLineNum += raw.length;
31553
+ }
31554
+ lastWasChange = false;
31555
+ }
31556
+ return { diff: output.join(`
31557
+ `), firstChangedLine };
31382
31558
  }
31383
- function shortenPath(path2) {
31384
- const home = homedir8();
31385
- if (path2.startsWith(home))
31386
- return `~${path2.slice(home.length)}`;
31387
- return path2;
31559
+
31560
+ // src/tools/hoisted.ts
31561
+ var DIAGNOSTICS_PARAM_DESCRIPTION = "When true, wait up to 3 seconds for fresh LSP diagnostics on the edited file and include them in the result. Defaults to the configured `lsp.diagnostics_on_edit` value (false unless configured); per-call true/false overrides. Use aft_inspect to check diagnostics across a batch of edits or before tests/commits.";
31562
+ function diagnosticsOnEditDefault(ctx) {
31563
+ return ctx.config.lsp?.diagnostics_on_edit ?? false;
31388
31564
  }
31389
- function renderToolCall(toolName, summary, theme, context) {
31390
- const text = reuseText(context.lastComponent);
31391
- const suffix = summary ? ` ${summary}` : "";
31392
- text.setText(`${theme.fg("toolTitle", theme.bold(toolName))}${suffix}`);
31393
- return text;
31565
+ function containsPath(parent, child) {
31566
+ const rel = relative3(parent, child);
31567
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
31394
31568
  }
31395
- function accentPath(theme, path2) {
31396
- if (!path2)
31397
- return theme.fg("toolOutput", "...");
31398
- return theme.fg("accent", shortenPath(path2));
31569
+ function expandTilde(path2) {
31570
+ if (!path2 || !path2.startsWith("~"))
31571
+ return path2;
31572
+ if (path2 === "~")
31573
+ return homedir8();
31574
+ if (path2.startsWith(`~${sep}`) || path2.startsWith("~/")) {
31575
+ return resolve3(homedir8(), path2.slice(2));
31576
+ }
31577
+ return path2;
31399
31578
  }
31400
- function collectTextContent(result) {
31401
- return result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join(`
31402
- `).trim();
31579
+ function externalDirectoryPromptTimeoutMs() {
31580
+ const raw = process.env.AFT_PI_EXTERNAL_PROMPT_TIMEOUT_MS;
31581
+ if (raw === undefined)
31582
+ return 30000;
31583
+ const parsed = Number.parseInt(raw, 10);
31584
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
31403
31585
  }
31404
- function extractStructuredPayload(result) {
31405
- if (result.details !== undefined)
31406
- return result.details;
31407
- const text = collectTextContent(result);
31408
- if (!text)
31586
+ async function assertExternalDirectoryPermission(extCtx, target, action = "modify", options = {}) {
31587
+ if (!target)
31409
31588
  return;
31410
- try {
31411
- return JSON.parse(text);
31412
- } catch {
31589
+ const expanded = expandTilde(target);
31590
+ const absoluteTarget = isAbsolute2(expanded) ? expanded : resolve3(extCtx.cwd, expanded);
31591
+ if (containsPath(extCtx.cwd, absoluteTarget))
31592
+ return;
31593
+ if (options.restrictToProjectRoot === false)
31413
31594
  return;
31595
+ const confirmFn = extCtx.ui?.confirm;
31596
+ if (extCtx.hasUI === false || !confirmFn) {
31597
+ throw new Error(`Permission denied: cannot prompt for ${action} outside the project (${absoluteTarget}).`);
31414
31598
  }
31415
- }
31416
- function renderErrorResult(result, fallback, theme, context) {
31417
- const text = reuseText(context.lastComponent);
31418
- const message = collectTextContent(result) || fallback;
31419
- text.setText(`
31420
- ${theme.fg("error", message)}`);
31421
- return text;
31422
- }
31423
- function renderSections(sections, context) {
31424
- const container = reuseContainer(context.lastComponent);
31425
- container.clear();
31426
- const visibleSections = sections.filter((section) => section.trim().length > 0);
31427
- if (visibleSections.length === 0)
31428
- return container;
31429
- container.addChild(new Spacer(1));
31430
- visibleSections.forEach((section, index) => {
31431
- if (index > 0)
31432
- container.addChild(new Spacer(1));
31433
- container.addChild(new Text(section, 0, 0));
31599
+ const timeoutMs = externalDirectoryPromptTimeoutMs();
31600
+ let timer;
31601
+ const timeoutPromise = new Promise((resolve4) => {
31602
+ timer = setTimeout(() => resolve4("timeout"), timeoutMs);
31434
31603
  });
31435
- return container;
31436
- }
31437
- function asRecord2(value) {
31438
- if (!value || typeof value !== "object" || Array.isArray(value))
31439
- return;
31440
- return value;
31441
- }
31442
- function asRecords(value) {
31443
- return Array.isArray(value) ? value.map(asRecord2).filter(Boolean) : [];
31444
- }
31445
- function asString(value) {
31446
- return typeof value === "string" ? value : undefined;
31447
- }
31448
- function asNumber(value) {
31449
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
31450
- }
31451
- function asBoolean(value) {
31452
- return typeof value === "boolean" ? value : undefined;
31453
- }
31454
- function formatValue(value) {
31455
- if (Array.isArray(value))
31456
- return value.map(formatValue).join(", ");
31457
- if (typeof value === "string")
31458
- return value;
31459
- if (typeof value === "number" || typeof value === "boolean")
31460
- return String(value);
31461
- if (value === null || value === undefined)
31462
- return "—";
31463
31604
  try {
31464
- return JSON.stringify(value);
31465
- } catch {
31466
- return String(value);
31467
- }
31468
- }
31469
- function groupByFile(items, getFile) {
31470
- const groups = new Map;
31471
- items.forEach((item) => {
31472
- const file2 = getFile(item) ?? "(unknown file)";
31473
- const current = groups.get(file2) ?? [];
31474
- current.push(item);
31475
- groups.set(file2, current);
31476
- });
31477
- return groups;
31478
- }
31479
- function formatTimestamp(value) {
31480
- if (typeof value === "string" && value.length > 0)
31481
- return value;
31482
- if (typeof value !== "number" || !Number.isFinite(value))
31483
- return;
31484
- const millis = value > 1000000000000 ? value : value * 1000;
31485
- const date5 = new Date(millis);
31486
- if (Number.isNaN(date5.getTime()))
31487
- return String(value);
31488
- return date5.toISOString().replace("T", " ").replace(".000Z", "Z");
31489
- }
31490
- function formatUnifiedDiffForPi(unifiedDiff) {
31491
- if (!unifiedDiff.trim())
31492
- return "";
31493
- const entries = [];
31494
- const hunkHeader = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
31495
- let oldLine = 1;
31496
- let newLine = 1;
31497
- for (const line of unifiedDiff.split(`
31498
- `)) {
31499
- if (!line)
31500
- continue;
31501
- if (line.startsWith("--- ") || line.startsWith("+++ "))
31502
- continue;
31503
- if (line.startsWith("\"))
31504
- continue;
31505
- const headerMatch = line.match(hunkHeader);
31506
- if (headerMatch) {
31507
- oldLine = Number(headerMatch[1]);
31508
- newLine = Number(headerMatch[2]);
31509
- continue;
31510
- }
31511
- if (line.startsWith("+") && !line.startsWith("+++")) {
31512
- entries.push({ prefix: "+", line: newLine, text: line.slice(1) });
31513
- newLine += 1;
31514
- continue;
31515
- }
31516
- if (line.startsWith("-") && !line.startsWith("---")) {
31517
- entries.push({ prefix: "-", line: oldLine, text: line.slice(1) });
31518
- oldLine += 1;
31519
- continue;
31520
- }
31521
- if (line.startsWith(" ")) {
31522
- entries.push({ prefix: " ", line: oldLine, text: line.slice(1) });
31523
- oldLine += 1;
31524
- newLine += 1;
31605
+ const result = await Promise.race([
31606
+ confirmFn("Allow external directory access?", `AFT wants to ${action} outside the project: ${absoluteTarget}`),
31607
+ timeoutPromise
31608
+ ]);
31609
+ if (result === true)
31610
+ return;
31611
+ if (result === "timeout") {
31612
+ throw new Error(`Permission denied: external directory prompt timed out after ${timeoutMs}ms.`);
31525
31613
  }
31526
- }
31527
- if (entries.length === 0)
31528
- return "";
31529
- const width = String(entries.reduce((max, entry) => Math.max(max, entry.line), 1)).length;
31530
- return entries.map((entry) => `${entry.prefix}${String(entry.line).padStart(width, " ")} ${entry.text}`).join(`
31531
- `);
31532
- }
31533
- function renderUnifiedDiff(unifiedDiff) {
31534
- const piDiff = formatUnifiedDiffForPi(unifiedDiff);
31535
- if (!piDiff)
31536
- return "";
31537
- try {
31538
- return renderDiff(piDiff);
31539
- } catch {
31540
- return piDiff;
31614
+ throw new Error("Permission denied: external directory access was cancelled.");
31615
+ } finally {
31616
+ if (timer !== undefined)
31617
+ clearTimeout(timer);
31541
31618
  }
31542
31619
  }
31543
-
31544
- // src/tools/ast.ts
31545
- var AstLang = StringEnum(["typescript", "tsx", "javascript", "python", "rust", "go"], {
31546
- description: "Target language"
31620
+ var ReadParams = Type2.Object({
31621
+ path: Type2.String({
31622
+ description: "Path to the file to read (absolute or relative to project root)"
31623
+ }),
31624
+ offset: optionalInt(1, Number.MAX_SAFE_INTEGER),
31625
+ limit: optionalInt(1, Number.MAX_SAFE_INTEGER)
31547
31626
  });
31548
- var SearchParams = Type2.Object({
31549
- pattern: Type2.String({
31550
- description: "AST pattern with meta-variables (`$VAR` matches one node, `$$$` matches many). Must be a complete AST node."
31627
+ var WriteParams = Type2.Object({
31628
+ filePath: Type2.String({
31629
+ description: "Path to the file to write (absolute or relative to project root)"
31551
31630
  }),
31552
- lang: AstLang,
31553
- paths: Type2.Optional(Type2.Array(Type2.String(), { description: "Paths to search (default: ['.'])" })),
31554
- globs: Type2.Optional(Type2.Array(Type2.String(), { description: "Include/exclude globs (prefix `!` to exclude)" })),
31555
- contextLines: Type2.Optional(Type2.Number({ description: "Number of context lines around each match" }))
31631
+ content: Type2.String({ description: "Full file contents to write" }),
31632
+ diagnostics: Type2.Optional(Type2.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
31556
31633
  });
31557
- var ReplaceParams = Type2.Object({
31558
- pattern: Type2.String({ description: "AST pattern with meta-variables" }),
31559
- rewrite: Type2.String({ description: "Replacement pattern, can reference $VAR from pattern" }),
31560
- lang: AstLang,
31561
- paths: Type2.Optional(Type2.Array(Type2.String(), { description: "Paths (default: ['.'])" })),
31562
- globs: Type2.Optional(Type2.Array(Type2.String(), { description: "Include/exclude globs" })),
31563
- dryRun: Type2.Optional(Type2.Boolean({ description: "Preview without applying (default: false)" }))
31634
+ var EditParams = Type2.Object({
31635
+ filePath: Type2.String({
31636
+ description: "Path to the file to edit (absolute or relative to project root)"
31637
+ }),
31638
+ oldString: Type2.Optional(Type2.String({ description: "Text to find (exact match, fuzzy fallback)" })),
31639
+ newString: Type2.Optional(Type2.String({ description: "Replacement text (omit to delete match)" })),
31640
+ replaceAll: Type2.Optional(Type2.Boolean({ description: "Replace every occurrence" })),
31641
+ occurrence: optionalInt(0, Number.MAX_SAFE_INTEGER),
31642
+ appendContent: Type2.Optional(Type2.String({
31643
+ description: "Append text to the end of the file (creates the file if missing, parent dirs auto-created). When set, oldString/newString are ignored."
31644
+ })),
31645
+ diagnostics: Type2.Optional(Type2.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
31564
31646
  });
31565
- function appendHintSection(response, sections, theme) {
31566
- const hint = asString(response.hint);
31567
- if (hint && hint.length > 0) {
31568
- sections.push(theme.fg("warning", hint));
31569
- }
31570
- }
31571
- function appendScopeSections(response, sections, theme) {
31572
- if (response.no_files_matched_scope === true) {
31573
- sections.push(theme.fg("warning", "No files matched the scope (paths/globs resolved to zero files)"));
31574
- }
31575
- const warnings = asRecords(response.scope_warnings);
31576
- const warningStrings = Array.isArray(response.scope_warnings) ? response.scope_warnings.filter((w) => typeof w === "string") : warnings.map((w) => asString(w.warning) ?? "").filter(Boolean);
31577
- if (warningStrings.length > 0) {
31578
- sections.push(`${theme.fg("muted", "Scope warnings:")}
31579
- ${warningStrings.map((w) => ` ${w}`).join(`
31580
- `)}`);
31581
- }
31582
- }
31583
- function buildAstSearchSections(payload, theme) {
31584
- const response = asRecord2(payload);
31585
- if (!response)
31586
- return [theme.fg("muted", "No AST search results.")];
31587
- const matches = asRecords(response.matches);
31588
- const totalMatches = asNumber(response.total_matches) ?? matches.length;
31589
- const filesWithMatches = asNumber(response.files_with_matches) ?? groupByFile(matches, (match) => asString(match.file)).size;
31590
- const filesSearched = asNumber(response.files_searched);
31591
- const header = [
31592
- theme.fg("success", `${totalMatches} match${totalMatches === 1 ? "" : "es"}`),
31593
- theme.fg("accent", `${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`),
31594
- filesSearched !== undefined ? theme.fg("muted", `${filesSearched} searched`) : undefined
31595
- ].filter(Boolean).join(" · ");
31596
- if (matches.length === 0) {
31597
- const sections2 = [header, theme.fg("muted", "No AST matches found.")];
31598
- appendScopeSections(response, sections2, theme);
31599
- appendHintSection(response, sections2, theme);
31600
- return sections2;
31647
+ var GrepParams = Type2.Object({
31648
+ pattern: Type2.String({ description: "Regex pattern to search for" }),
31649
+ path: Type2.Optional(Type2.String({
31650
+ description: "Path scope (file or directory; absolute or relative to project root)"
31651
+ })),
31652
+ include: Type2.Optional(Type2.String({ description: "Glob filter for included files (e.g. '*.ts,*.tsx')" })),
31653
+ caseSensitive: Type2.Optional(Type2.Boolean({ description: "Case-sensitive matching" })),
31654
+ contextLines: optionalInt(1, Number.MAX_SAFE_INTEGER)
31655
+ });
31656
+ function registerHoistedTools(pi, ctx, surface) {
31657
+ if (surface.hoistRead) {
31658
+ pi.registerTool({
31659
+ name: "read",
31660
+ label: "read",
31661
+ description: "Read file contents with line numbers. Backed by AFT's indexed Rust reader — faster than the built-in `read` on large repos and correctly handles images/PDFs as attachments.",
31662
+ promptSnippet: "Read file contents (supports offset/limit for large files)",
31663
+ promptGuidelines: ["Use read to examine files instead of cat or sed."],
31664
+ parameters: ReadParams,
31665
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31666
+ const bridge = bridgeFor(ctx, extCtx.cwd);
31667
+ const offset = coerceOptionalInt(params.offset, "offset", 1, Number.MAX_SAFE_INTEGER);
31668
+ const limit = coerceOptionalInt(params.limit, "limit", 1, Number.MAX_SAFE_INTEGER);
31669
+ const req = { file: params.path };
31670
+ if (offset !== undefined) {
31671
+ req.start_line = offset;
31672
+ if (limit !== undefined) {
31673
+ req.end_line = offset + limit - 1;
31674
+ }
31675
+ } else if (limit !== undefined) {
31676
+ req.end_line = limit;
31677
+ }
31678
+ const response = await callBridge(bridge, "read", req, extCtx);
31679
+ if (Array.isArray(response.entries)) {
31680
+ return textResult(response.entries.join(`
31681
+ `));
31682
+ }
31683
+ let text = response.content ?? "";
31684
+ const agentSpecifiedRange = offset !== undefined || limit !== undefined;
31685
+ const footer = formatReadFooter(agentSpecifiedRange, response);
31686
+ if (footer)
31687
+ text += footer;
31688
+ return textResult(text);
31689
+ }
31690
+ });
31601
31691
  }
31602
- const grouped = groupByFile(matches, (match) => asString(match.file));
31603
- const sections = [header];
31604
- for (const [file2, fileMatches] of grouped.entries()) {
31605
- const lines = [theme.fg("accent", shortenPath(file2))];
31606
- fileMatches.forEach((match, index) => {
31607
- const line = asNumber(match.line) ?? 0;
31608
- const column = asNumber(match.column) ?? 0;
31609
- const snippet = asString(match.text)?.trim() || "(empty match)";
31610
- lines.push(` ${index + 1}. ${theme.fg("muted", `${line}:${column}`)} ${snippet}`);
31611
- const metaVars = asRecord2(match.meta_variables);
31612
- if (metaVars && Object.keys(metaVars).length > 0) {
31613
- Object.entries(metaVars).forEach(([name, value]) => {
31614
- lines.push(` ${theme.fg("muted", `${name} =`)} ${formatValue(value)}`);
31692
+ if (surface.hoistWrite) {
31693
+ pi.registerTool({
31694
+ name: "write",
31695
+ label: "write",
31696
+ description: "Write a file atomically with per-file backup and optional auto-format. Parent directories are created automatically. Overwrites existing files. Uses `filePath` (not `path`). Edits return as soon as the write completes unless `lsp.diagnostics_on_edit` or a per-call `diagnostics: true` requests legacy sync-wait behavior. Call `aft_inspect` afterward to check diagnostics across a batch of edits.",
31697
+ promptSnippet: "Create or overwrite files (uses filePath; auto-formats; diagnostics follow lsp.diagnostics_on_edit unless overridden)",
31698
+ promptGuidelines: ["Use write only for new files or complete rewrites."],
31699
+ parameters: WriteParams,
31700
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31701
+ await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
31702
+ restrictToProjectRoot: surface.restrictToProjectRoot
31615
31703
  });
31704
+ const bridge = bridgeFor(ctx, extCtx.cwd);
31705
+ const response = await callBridge(bridge, "write", {
31706
+ file: params.filePath,
31707
+ content: params.content,
31708
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31709
+ include_diff_content: true
31710
+ }, extCtx);
31711
+ return buildMutationResult(params.filePath, response);
31712
+ },
31713
+ renderCall(args, theme, context) {
31714
+ return renderMutationCall("write", args?.filePath, theme, context);
31715
+ },
31716
+ renderResult(result, _options, theme, context) {
31717
+ return renderMutationResult(result, theme, context);
31616
31718
  }
31617
- const context = asRecords(match.context);
31618
- context.forEach((ctxLine) => {
31619
- const ctxNumber = asNumber(ctxLine.line) ?? 0;
31620
- const prefix = ctxLine.is_match === true ? theme.fg("accent", ">") : theme.fg("muted", "|");
31621
- lines.push(` ${prefix} ${ctxNumber}: ${asString(ctxLine.text) ?? ""}`);
31622
- });
31623
31719
  });
31624
- sections.push(lines.join(`
31625
- `));
31626
- }
31627
- return sections;
31628
- }
31629
- function buildAstReplaceSections(payload, theme) {
31630
- const response = asRecord2(payload);
31631
- if (!response)
31632
- return [theme.fg("muted", "No AST replace results.")];
31633
- const files = asRecords(response.files);
31634
- const totalReplacements = asNumber(response.total_replacements) ?? 0;
31635
- const totalFiles = asNumber(response.total_files) ?? files.length;
31636
- const filesWithMatches = asNumber(response.files_with_matches);
31637
- const dryRun = response.dry_run === true;
31638
- const headerParts = [
31639
- dryRun ? theme.fg("warning", "[dry run]") : theme.fg("success", "[applied]"),
31640
- `${totalReplacements} replacement${totalReplacements === 1 ? "" : "s"}`,
31641
- `${totalFiles} file${totalFiles === 1 ? "" : "s"}`,
31642
- filesWithMatches !== undefined ? theme.fg("muted", `${filesWithMatches} matched`) : undefined
31643
- ];
31644
- const sections = [headerParts.filter(Boolean).join(" ")];
31645
- if (files.length === 0) {
31646
- sections.push(theme.fg("muted", "No files changed."));
31647
- appendScopeSections(response, sections, theme);
31648
- appendHintSection(response, sections, theme);
31649
- return sections;
31650
31720
  }
31651
- files.forEach((fileResult) => {
31652
- const file2 = shortenPath(asString(fileResult.file) ?? "(unknown file)");
31653
- const replacements = asNumber(fileResult.replacements) ?? 0;
31654
- const error50 = asString(fileResult.error);
31655
- const diff = asString(fileResult.diff);
31656
- const lines = [
31657
- `${theme.fg("accent", file2)} ${theme.fg("muted", `(${replacements} replacement${replacements === 1 ? "" : "s"})`)}`
31658
- ];
31659
- if (error50) {
31660
- lines.push(theme.fg("error", error50));
31661
- } else if (diff) {
31662
- const rendered = renderUnifiedDiff(diff);
31663
- lines.push(rendered || theme.fg("muted", "No diff available."));
31664
- } else {
31665
- const backupId = asString(fileResult.backup_id);
31666
- lines.push(backupId ? `${theme.fg("success", "saved")} ${theme.fg("muted", backupId)}` : theme.fg("success", "saved"));
31667
- }
31668
- sections.push(lines.join(`
31669
- `));
31670
- });
31671
- return sections;
31672
- }
31673
- function renderAstCall(toolName, args, theme, context) {
31674
- const lang = theme.fg("accent", args.lang);
31675
- const summary = toolName === "ast_grep_replace" ? `${lang} ${theme.fg("toolOutput", `${args.pattern} → ${args.rewrite}`)}` : `${lang} ${theme.fg("toolOutput", args.pattern)}`;
31676
- return renderToolCall(toolName === "ast_grep_replace" ? "ast replace" : "ast search", summary, theme, context);
31677
- }
31678
- function renderAstResult(toolName, result, theme, context) {
31679
- if (context.isError) {
31680
- return renderErrorResult(result, `${toolName} failed`, theme, context);
31681
- }
31682
- const payload = extractStructuredPayload(result);
31683
- if (!payload) {
31684
- const text = collectTextContent(result);
31685
- return renderSections([text || theme.fg("muted", "No result.")], context);
31686
- }
31687
- const sections = toolName === "ast_grep_replace" ? buildAstReplaceSections(payload, theme) : buildAstSearchSections(payload, theme);
31688
- return renderSections(sections, context);
31689
- }
31690
- function registerAstTools(pi, ctx, surface) {
31691
- if (surface.astSearch) {
31721
+ if (surface.hoistEdit) {
31692
31722
  pi.registerTool({
31693
- name: "ast_grep_search",
31694
- label: "ast search",
31695
- description: "Search code patterns across the filesystem using AST-aware matching. Use `$VAR` to match a single AST node, `$$$` for multiple. Pattern must be a complete, valid code fragment (include braces, params, etc.).",
31696
- parameters: SearchParams,
31723
+ name: "edit",
31724
+ label: "edit",
31725
+ description: "Find-and-replace edit with progressive fuzzy matching (handles whitespace and Unicode drift). Uses `filePath`, `oldString`, `newString`. Errors on multiple matches — use `occurrence` to pick one, or `replaceAll: true`. Edits return as soon as the write completes unless `lsp.diagnostics_on_edit` or a per-call `diagnostics: true` requests legacy sync-wait behavior. Call `aft_inspect` afterward to check diagnostics across a batch of edits.",
31726
+ promptSnippet: "Targeted find-and-replace (uses filePath/oldString/newString; occurrence or replaceAll for disambiguation; fuzzy whitespace matching). Pass appendContent to append to a file (creates if missing). Diagnostics follow lsp.diagnostics_on_edit unless overridden.",
31727
+ promptGuidelines: [
31728
+ "Prefer edit over write when changing part of an existing file.",
31729
+ "Include enough surrounding context in oldString to make the match unique, or set replaceAll/occurrence explicitly.",
31730
+ "Use appendContent (instead of read+write) when adding text to the end of a file."
31731
+ ],
31732
+ parameters: EditParams,
31697
31733
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31734
+ await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
31735
+ restrictToProjectRoot: surface.restrictToProjectRoot
31736
+ });
31698
31737
  const bridge = bridgeFor(ctx, extCtx.cwd);
31738
+ if (typeof params.appendContent === "string") {
31739
+ const req2 = {
31740
+ op: "append",
31741
+ file: params.filePath,
31742
+ append_content: params.appendContent,
31743
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31744
+ include_diff_content: true
31745
+ };
31746
+ const response2 = await callBridge(bridge, "edit_match", req2, extCtx);
31747
+ return buildMutationResult(params.filePath, response2);
31748
+ }
31699
31749
  const req = {
31700
- pattern: params.pattern,
31701
- lang: params.lang
31750
+ file: params.filePath,
31751
+ match: params.oldString ?? "",
31752
+ replacement: params.newString ?? "",
31753
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31754
+ include_diff_content: true
31702
31755
  };
31703
- if (!isEmptyParam(params.paths))
31704
- req.paths = params.paths;
31705
- if (!isEmptyParam(params.globs))
31706
- req.globs = params.globs;
31707
- if (params.contextLines !== undefined)
31708
- req.context_lines = params.contextLines;
31709
- const response = await callBridge(bridge, "ast_search", req, extCtx);
31710
- return textResult(response.text ?? JSON.stringify(response));
31756
+ if (params.replaceAll === true)
31757
+ req.replace_all = true;
31758
+ const occurrence = coerceOptionalInt(params.occurrence, "occurrence", 0, Number.MAX_SAFE_INTEGER);
31759
+ if (occurrence !== undefined)
31760
+ req.occurrence = occurrence;
31761
+ const response = await callBridge(bridge, "edit_match", req, extCtx);
31762
+ return buildMutationResult(params.filePath, response);
31711
31763
  },
31712
31764
  renderCall(args, theme, context) {
31713
- return renderAstCall("ast_grep_search", args, theme, context);
31765
+ return renderMutationCall("edit", args?.filePath, theme, context);
31714
31766
  },
31715
31767
  renderResult(result, _options, theme, context) {
31716
- return renderAstResult("ast_grep_search", result, theme, context);
31768
+ return renderMutationResult(result, theme, context);
31717
31769
  }
31718
31770
  });
31719
31771
  }
31720
- if (surface.astReplace) {
31772
+ if (surface.hoistGrep) {
31721
31773
  pi.registerTool({
31722
- name: "ast_grep_replace",
31723
- label: "ast replace",
31724
- description: "Replace code patterns across the filesystem with AST-aware rewriting. Applies by default pass `dryRun: true` to preview. Use meta-variables in `rewrite` to preserve captured content from the pattern.",
31725
- parameters: ReplaceParams,
31774
+ name: "grep",
31775
+ label: "grep",
31776
+ description: "Search for a regex pattern across files. Uses AFT's trigram index inside the project root for fast repeated queries, and falls back to ripgrep for paths outside the project root.",
31777
+ promptSnippet: "Fast regex search across files (trigram-indexed inside the project root)",
31778
+ promptGuidelines: ["Prefer grep over bash-invoked find/rg for in-project searches."],
31779
+ parameters: GrepParams,
31726
31780
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31727
31781
  const bridge = bridgeFor(ctx, extCtx.cwd);
31728
- const req = {
31729
- pattern: params.pattern,
31730
- rewrite: params.rewrite,
31731
- lang: params.lang
31732
- };
31733
- if (!isEmptyParam(params.paths))
31734
- req.paths = params.paths;
31735
- if (!isEmptyParam(params.globs))
31736
- req.globs = params.globs;
31737
- req.dry_run = params.dryRun === true;
31738
- const response = await callBridge(bridge, "ast_replace", req, extCtx);
31739
- return textResult(response.text ?? JSON.stringify(response));
31740
- },
31741
- renderCall(args, theme, context) {
31742
- return renderAstCall("ast_grep_replace", args, theme, context);
31743
- },
31744
- renderResult(result, _options, theme, context) {
31745
- return renderAstResult("ast_grep_replace", result, theme, context);
31782
+ const req = { pattern: params.pattern };
31783
+ if (params.path) {
31784
+ await assertExternalDirectoryPermission(extCtx, params.path, "search", {
31785
+ restrictToProjectRoot: surface.restrictToProjectRoot
31786
+ });
31787
+ req.path = await resolvePathArg(extCtx.cwd, params.path);
31788
+ }
31789
+ if (params.include)
31790
+ req.include = splitIncludeGlobs(params.include);
31791
+ if (params.caseSensitive !== undefined)
31792
+ req.case_sensitive = params.caseSensitive;
31793
+ const contextLines = coerceOptionalInt(params.contextLines, "contextLines", 1, Number.MAX_SAFE_INTEGER);
31794
+ if (contextLines !== undefined)
31795
+ req.context_lines = contextLines;
31796
+ const response = await callBridge(bridge, "grep", req, extCtx);
31797
+ const text = response.text ?? "";
31798
+ return textResult(text);
31746
31799
  }
31747
31800
  });
31748
31801
  }
31749
31802
  }
31803
+ function buildMutationResult(filePath, response) {
31804
+ const diffObj = response.diff;
31805
+ const additions = diffObj?.additions ?? 0;
31806
+ const deletions = diffObj?.deletions ?? 0;
31807
+ const replacements = response.replacements;
31808
+ const diagnostics = response.lsp_diagnostics;
31809
+ const truncated = diffObj?.truncated === true;
31810
+ const noOp = response.no_op === true;
31811
+ const formatted = response.formatted;
31812
+ const formatSkippedReason = response.format_skipped_reason;
31813
+ const globFormatSkipReasons = response.format_skip_reasons;
31814
+ let diffText;
31815
+ let firstChangedLine;
31816
+ if (diffObj && !truncated && typeof diffObj.before === "string" && typeof diffObj.after === "string") {
31817
+ const piDiff = formatDiffForPi(diffObj.before, diffObj.after);
31818
+ diffText = piDiff.diff;
31819
+ firstChangedLine = piDiff.firstChangedLine;
31820
+ }
31821
+ const summaryHeader = replacements !== undefined ? `Edited ${filePath} (+${additions}/-${deletions}, ${replacements} replacement${replacements === 1 ? "" : "s"})` : `Wrote ${filePath} (+${additions}/-${deletions})`;
31822
+ let text = summaryHeader;
31823
+ if (noOp) {
31824
+ text += `
31750
31825
 
31751
- // src/tools/bash.ts
31752
- import * as fs3 from "node:fs/promises";
31753
- import { Container as Container2, Spacer as Spacer2, Text as Text2 } from "@earendil-works/pi-tui";
31754
- import { Type as Type3 } from "typebox";
31755
- var FOREGROUND_WAIT_WINDOW_MS = 5000;
31756
- var FOREGROUND_POLL_INTERVAL_MS = 100;
31757
- var BASH_WAIT_POLL_INTERVAL_MS = 100;
31758
- var DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS = 30000;
31759
- var MAX_BASH_STATUS_WAIT_TIMEOUT_MS = 300000;
31760
- var BASH_TRANSPORT_TIMEOUT_MS = 30000;
31761
- var BashParams = Type3.Object({
31762
- command: Type3.String({
31763
- description: "Shell command to execute. Supports pipes, redirections, and shell syntax."
31764
- }),
31765
- timeout: optionalInt(1, Number.MAX_SAFE_INTEGER),
31766
- workdir: Type3.Optional(Type3.String({
31767
- description: "Working directory for command execution. Relative paths resolve against the project root. Defaults to the current session's working directory."
31768
- })),
31769
- description: Type3.Optional(Type3.String({
31770
- description: "Human-readable description shown in UI logs. Helps users understand what the command does without reading shell syntax."
31771
- })),
31772
- background: Type3.Optional(Type3.Boolean({
31773
- description: "Spawn command in background and return immediately with a task_id. Use bash_status to poll completion and bash_kill to terminate. Ideal for long-running tasks like builds or dev servers."
31774
- })),
31775
- compressed: Type3.Optional(Type3.Boolean({
31776
- description: "Compress output by removing ANSI codes, carriage returns, and excessive blank lines. Default: true. Set to false for raw terminal output including color codes."
31777
- })),
31778
- pty: Type3.Optional(Type3.Boolean({
31779
- description: 'Spawn the command in a real PTY for interactive programs. Implies background: true automatically. Inspect with bash_status({ task_id, output_mode: "screen" }) and send input with bash_write.'
31780
- })),
31781
- ptyRows: optionalInt(1, 60),
31782
- ptyCols: optionalInt(1, 140)
31783
- });
31784
- var BashTaskParams = Type3.Object({
31785
- task_id: Type3.String({
31786
- description: "Background bash task id returned by bash({ background: true })."
31787
- })
31788
- });
31789
- var BashStatusParams = Type3.Object({
31790
- task_id: Type3.String({
31791
- description: "Background bash task id returned by bash({ background: true })."
31792
- }),
31793
- output_mode: Type3.Optional(Type3.Union([Type3.Literal("screen"), Type3.Literal("raw"), Type3.Literal("both")], {
31794
- description: "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted."
31795
- }))
31796
- });
31797
- var BashWatchParams = Type3.Object({
31798
- task_id: Type3.String({
31799
- description: "Background bash task id returned by bash({ background: true })."
31800
- }),
31801
- pattern: Type3.Optional(Type3.Union([Type3.String(), Type3.Object({ regex: Type3.String() })])),
31802
- background: Type3.Optional(Type3.Boolean()),
31803
- timeout_ms: optionalInt(1, MAX_BASH_STATUS_WAIT_TIMEOUT_MS),
31804
- once: Type3.Optional(Type3.Boolean())
31805
- });
31806
- var BashWriteParams = Type3.Object({
31807
- task_id: Type3.String({
31808
- description: "Background PTY task id returned by bash({ pty: true, background: true })."
31809
- }),
31810
- input: Type3.Union([
31811
- Type3.String(),
31812
- Type3.Array(Type3.Union([
31813
- Type3.String(),
31814
- Type3.Object({
31815
- key: Type3.String({
31816
- description: "Named control key, e.g. 'esc', 'enter', 'up', 'ctrl-c'. Case-insensitive."
31817
- })
31818
- })
31819
- ]))
31820
- ], {
31821
- description: "Either a string of verbatim bytes (e.g. 'print(1)\\n') OR an array mixing strings " + "and { key: '<name>' } objects for atomic text+key sequences. " + "Example: [ 'iHello', { key: 'esc' }, ':wq', { key: 'enter' } ]. " + "Allowed key names: enter, return, tab, space, backspace, esc, escape, up, down, " + "left, right, home, end, page-up, page-down, delete, insert, f1..f12, ctrl-a..ctrl-z."
31822
- })
31823
- });
31824
- function truncateToVisualLines(text, maxLines) {
31825
- const lines = text.split(`
31826
- `);
31827
- if (lines.length <= maxLines)
31828
- return text;
31829
- return lines.slice(-maxLines).join(`
31830
- `);
31831
- }
31832
- function reuseText2(last) {
31833
- return last instanceof Text2 ? last : new Text2("", 0, 0);
31834
- }
31835
- function reuseContainer2(last) {
31836
- return last instanceof Container2 ? last : new Container2;
31837
- }
31838
- function getBashSpawnHook(pi) {
31839
- const api2 = pi;
31840
- if (typeof api2.getHook === "function") {
31841
- return api2.getHook("bashSpawn");
31826
+ Note: no net file change — the match was found and applied, but the file content is byte-identical to before. Likely causes: oldString and newString are identical, or a formatter normalized the change away.`;
31842
31827
  }
31843
- return api2.hooks?.bashSpawn;
31828
+ const skipNote = formatSkipReasonNote(formatSkippedReason);
31829
+ if (skipNote)
31830
+ text += `
31831
+
31832
+ ${skipNote}`;
31833
+ const globSkipNote = formatGlobSkipReasonsNote(globFormatSkipReasons);
31834
+ if (globSkipNote)
31835
+ text += `
31836
+
31837
+ ${globSkipNote}`;
31838
+ if (diagnostics && diagnostics.length > 0) {
31839
+ text += `
31840
+
31841
+ LSP diagnostics:
31842
+ ${formatDiagnosticsText(diagnostics)}`;
31843
+ }
31844
+ return {
31845
+ content: [{ type: "text", text }],
31846
+ details: {
31847
+ diff: diffText,
31848
+ firstChangedLine,
31849
+ additions,
31850
+ deletions,
31851
+ replacements,
31852
+ diagnostics,
31853
+ truncated: truncated || undefined,
31854
+ formatted,
31855
+ formatSkippedReason,
31856
+ noOp: noOp || undefined
31857
+ }
31858
+ };
31844
31859
  }
31845
- function registerBashTool(pi, ctx) {
31846
- const spawnHook = getBashSpawnHook(pi);
31847
- pi.registerTool({
31848
- name: "bash",
31849
- label: "bash",
31850
- description: 'Execute shell commands through AFT\'s Rust bash handler. By default, output is compressed. Pass `compressed: false` for raw output. Pass `background: true` to spawn in the background and get a task_id for `bash_status`/`bash_kill`. Pass `pty: true` with `background: true` for interactive programs and drive them with `bash_status({ output_mode: "screen" })` plus `bash_write`.',
31851
- promptSnippet: "Run shell commands (timeout in milliseconds; supports workdir, background tasks, compressed output, PTY mode)",
31852
- promptGuidelines: [
31853
- "Use bash only when a dedicated AFT tool is not a better fit.",
31854
- "Set compressed: false when you need ANSI color codes in the output."
31855
- ],
31856
- parameters: BashParams,
31857
- async execute(_toolCallId, params, _signal, onUpdate, extCtx) {
31858
- const bridge = bridgeFor(ctx, extCtx.cwd);
31859
- const timeout = coerceOptionalInt(params.timeout, "timeout", 1, Number.MAX_SAFE_INTEGER);
31860
- const ptyRows = coerceOptionalInt(params.ptyRows, "ptyRows", 1, 60);
31861
- const ptyCols = coerceOptionalInt(params.ptyCols, "ptyCols", 1, 140);
31862
- const effectiveBackground = params.background === true || params.pty === true;
31863
- let spawnContext = {
31864
- command: params.command,
31865
- cwd: params.workdir
31866
- };
31867
- if (spawnHook) {
31868
- try {
31869
- spawnContext = await spawnHook(spawnContext);
31870
- } catch (hookErr) {
31871
- throw new Error(`BashSpawnHook failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
31872
- }
31873
- }
31874
- let streamed = "";
31875
- const response = await callBridge(bridge, "bash", {
31876
- command: spawnContext.command,
31877
- timeout,
31878
- workdir: spawnContext.cwd ?? params.workdir,
31879
- env: spawnContext.env,
31880
- description: params.description,
31881
- background: effectiveBackground,
31882
- notify_on_completion: effectiveBackground,
31883
- compressed: params.compressed,
31884
- pty: params.pty,
31885
- pty_rows: ptyRows,
31886
- pty_cols: ptyCols
31887
- }, extCtx, {
31888
- transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS,
31889
- keepBridgeOnTimeout: true,
31890
- onProgress: ({ text }) => {
31891
- streamed += text;
31892
- const displayText = truncateToVisualLines(streamed, 100);
31893
- onUpdate?.(bashResult(displayText, { streaming: true }));
31894
- }
31895
- }).catch((err) => {
31896
- if (err instanceof Error && err.message.includes("permission_required")) {
31897
- throw new Error("Permission ask reached Pi adapter — this is a bug. Pi has no permission system.");
31898
- }
31899
- throw err;
31900
- });
31901
- if (response.success === false) {
31902
- throw new Error(response.message ?? "bash failed");
31903
- }
31904
- const taskId = response.task_id;
31905
- if (response.status === "running" && taskId) {
31906
- if (effectiveBackground) {
31907
- trackBgTask(resolveSessionId(extCtx), taskId);
31908
- return bashResult(formatBackgroundLaunch(taskId, params.pty === true), {
31909
- task_id: taskId
31910
- });
31911
- }
31912
- const waitTimeoutMs = timeout !== undefined ? Math.min(timeout, FOREGROUND_WAIT_WINDOW_MS) : FOREGROUND_WAIT_WINDOW_MS;
31913
- const startedAt = Date.now();
31914
- while (true) {
31915
- const status = await callBridge(bridge, "bash_status", { task_id: taskId }, extCtx);
31916
- if (status.success === false) {
31917
- throw new Error(status.message ?? "bash_status failed");
31918
- }
31919
- if (isTerminalStatus(status.status)) {
31920
- return bashResult(formatForegroundResult(status), {
31921
- exit_code: status.exit_code,
31922
- duration_ms: status.duration_ms,
31923
- truncated: status.output_truncated,
31924
- output_path: status.output_path,
31925
- task_id: taskId
31926
- });
31927
- }
31928
- if (Date.now() - startedAt >= waitTimeoutMs) {
31929
- const promoted = await callBridge(bridge, "bash_promote", { task_id: taskId }, extCtx);
31930
- if (promoted.success === false) {
31931
- throw new Error(promoted.message ?? "bash_promote failed");
31932
- }
31933
- trackBgTask(resolveSessionId(extCtx), taskId);
31934
- return bashResult(formatPromotionMessage(taskId, params.timeout), {
31935
- task_id: taskId
31936
- });
31937
- }
31938
- await sleep(FOREGROUND_POLL_INTERVAL_MS);
31939
- }
31940
- }
31941
- const details = {
31942
- exit_code: response.exit_code,
31943
- duration_ms: response.duration_ms,
31944
- truncated: response.truncated,
31945
- output_path: response.output_path,
31946
- task_id: taskId
31947
- };
31948
- const output = response.output ?? "";
31949
- return bashResult(output, details);
31950
- },
31951
- renderCall(args, theme, context) {
31952
- return renderBashCall(args?.command, args?.description, theme, context);
31953
- },
31954
- renderResult(result, _options, theme, context) {
31955
- return renderBashResult(result, theme, context);
31956
- }
31957
- });
31958
- pi.registerTool(createBashStatusTool(ctx));
31959
- pi.registerTool(createBashWatchTool(ctx));
31960
- pi.registerTool(createBashWriteTool(ctx));
31961
- pi.registerTool(createBashKillTool(ctx));
31860
+ function formatGlobSkipReasonsNote(reasons) {
31861
+ if (!Array.isArray(reasons))
31862
+ return;
31863
+ const actionable = reasons.filter((reason) => typeof reason === "string").filter((reason) => ["formatter_not_installed", "formatter_excluded_path", "timeout", "error"].includes(reason));
31864
+ if (actionable.length === 0)
31865
+ return;
31866
+ return `Note: formatter skipped some glob edit result file(s): ${[...new Set(actionable)].sort().join(", ")}. See per-file format_skipped_reason values for details.`;
31962
31867
  }
31963
- function formatBackgroundLaunch(taskId, isPty) {
31964
- if (isPty) {
31965
- return `PTY task started: ${taskId}. Use bash_status({ task_id: "${taskId}", output_mode: "screen" }) to see the visible terminal, bash_write({ task_id: "${taskId}", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits.`;
31868
+ function formatSkipReasonNote(reason) {
31869
+ switch (reason) {
31870
+ case "formatter_not_installed":
31871
+ return "Note: formatter binary not installed; file written unformatted.";
31872
+ case "timeout":
31873
+ return "Note: formatter timed out; file written unformatted. Raise formatter_timeout_secs or check the formatter for hangs.";
31874
+ case "formatter_excluded_path":
31875
+ return "Note: formatter is configured to ignore this path (e.g. biome.json files.includes, .prettierignore). File written unformatted.";
31876
+ case "error":
31877
+ return "Note: formatter exited with an unrecognized error; file written unformatted.";
31878
+ default:
31879
+ return;
31966
31880
  }
31967
- return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
31968
- }
31969
- function formatPromotionMessage(taskId, timeout) {
31970
- const waited = timeout !== undefined ? Math.min(timeout, FOREGROUND_WAIT_WINDOW_MS) : FOREGROUND_WAIT_WINDOW_MS;
31971
- return `Foreground bash didn't finish within ${waited}ms and was promoted to background: ${taskId}. A completion reminder will be delivered automatically; use bash_status({ task_id: "${taskId}" }) to inspect output or bash_kill({ task_id: "${taskId}" }) to terminate.`;
31972
31881
  }
31973
- function formatForegroundResult(data) {
31974
- const output = data.output_preview ?? "";
31975
- const outputPath = data.output_path;
31976
- const truncated = data.output_truncated === true;
31977
- const status = data.status;
31978
- const exit = data.exit_code;
31979
- let rendered = output;
31980
- if (truncated && outputPath) {
31981
- rendered += `
31982
- [output truncated; full output at ${outputPath}]`;
31882
+ function formatDiagnosticsText(diagnostics) {
31883
+ try {
31884
+ return diagnostics.map((d) => {
31885
+ if (d && typeof d === "object") {
31886
+ const obj = d;
31887
+ const line = obj.line ?? obj.startLine ?? "?";
31888
+ const severity = obj.severity ?? "info";
31889
+ const msg = obj.message ?? JSON.stringify(obj);
31890
+ return ` [${severity}] line ${line}: ${msg}`;
31891
+ }
31892
+ return ` ${String(d)}`;
31893
+ }).join(`
31894
+ `);
31895
+ } catch {
31896
+ return JSON.stringify(diagnostics, null, 2);
31983
31897
  }
31984
- if (status === "timed_out") {
31985
- rendered += `
31986
- [command timed out]`;
31898
+ }
31899
+ function reuseText(last) {
31900
+ return last instanceof Text ? last : new Text("", 0, 0);
31901
+ }
31902
+ function reuseContainer(last) {
31903
+ return last instanceof Container ? last : new Container;
31904
+ }
31905
+ function renderMutationCall(toolName, filePath, theme, context) {
31906
+ const text = reuseText(context.lastComponent);
31907
+ const pathDisplay = filePath ? theme.fg("accent", shortenPath(filePath)) : theme.fg("toolOutput", "...");
31908
+ text.setText(`${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`);
31909
+ return text;
31910
+ }
31911
+ function renderMutationResult(result, theme, context) {
31912
+ if (context.isError) {
31913
+ const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
31914
+ `).trim();
31915
+ const text = reuseText(context.lastComponent);
31916
+ text.setText(`
31917
+ ${theme.fg("error", errorText || "edit failed")}`);
31918
+ return text;
31987
31919
  }
31988
- if (typeof exit === "number" && exit !== 0) {
31989
- rendered += `
31990
- [exit code: ${exit}]`;
31920
+ const details = result.details;
31921
+ const diff = typeof details?.diff === "string" ? details.diff : undefined;
31922
+ if (!diff) {
31923
+ const additions = details?.additions ?? 0;
31924
+ const deletions = details?.deletions ?? 0;
31925
+ const text = reuseText(context.lastComponent);
31926
+ const summary = theme.fg("success", `+${additions}/-${deletions}`);
31927
+ let suffix = "";
31928
+ if (details?.truncated) {
31929
+ suffix = ` ${theme.fg("muted", "(diff truncated)")}`;
31930
+ } else if (details?.noOp) {
31931
+ suffix = ` ${theme.fg("muted", "(no net change)")}`;
31932
+ }
31933
+ text.setText(`
31934
+ ${summary}${suffix}`);
31935
+ return text;
31991
31936
  }
31992
- return rendered;
31937
+ const container = reuseContainer(context.lastComponent);
31938
+ container.clear();
31939
+ container.addChild(new Spacer(1));
31940
+ container.addChild(new Text(renderDiff(diff), 1, 0));
31941
+ return container;
31993
31942
  }
31994
- function sleep(ms) {
31995
- return new Promise((resolve3) => setTimeout(resolve3, ms));
31943
+ function shortenPath(path2) {
31944
+ const home = homedir8();
31945
+ if (path2.startsWith(home))
31946
+ return `~${path2.slice(home.length)}`;
31947
+ return path2;
31996
31948
  }
31997
- function createBashStatusTool(ctx) {
31998
- return {
31999
- name: "bash_status",
32000
- label: "bash_status",
32001
- description: "Read-only snapshot of a background bash task. Returns immediately. Never waits. Use bash_watch to block on or register for pattern matches and exit events.",
32002
- promptSnippet: "Inspect a background bash task by task_id",
32003
- parameters: BashStatusParams,
32004
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32005
- const bridge = bridgeFor(ctx, extCtx.cwd);
32006
- const data = await bashStatusSnapshot(bridge, extCtx, params.task_id, params.output_mode);
32007
- const details = data;
32008
- return bashStatusResult(await formatBashStatus(extCtx, params.task_id, details, params.output_mode), details);
31949
+ async function resolvePathArg(cwd, path2) {
31950
+ const expanded = expandTilde(path2);
31951
+ const abs = isAbsolute2(expanded) ? expanded : resolve3(cwd, expanded);
31952
+ try {
31953
+ await stat(abs);
31954
+ return abs;
31955
+ } catch {
31956
+ return expanded;
31957
+ }
31958
+ }
31959
+ function splitIncludeGlobs(include) {
31960
+ const out = [];
31961
+ let depth = 0;
31962
+ let buf = "";
31963
+ for (const ch of include) {
31964
+ if (ch === "{") {
31965
+ depth++;
31966
+ buf += ch;
31967
+ continue;
32009
31968
  }
32010
- };
31969
+ if (ch === "}") {
31970
+ if (depth > 0)
31971
+ depth--;
31972
+ buf += ch;
31973
+ continue;
31974
+ }
31975
+ if (ch === "," && depth === 0) {
31976
+ const trimmed = buf.trim();
31977
+ if (trimmed.length > 0)
31978
+ out.push(trimmed);
31979
+ buf = "";
31980
+ continue;
31981
+ }
31982
+ buf += ch;
31983
+ }
31984
+ const tail2 = buf.trim();
31985
+ if (tail2.length > 0)
31986
+ out.push(tail2);
31987
+ return out;
32011
31988
  }
32012
- function createBashWatchTool(ctx) {
32013
- return {
32014
- name: "bash_watch",
32015
- label: "bash_watch",
32016
- description: "Block on a background bash task until a pattern matches, it exits, or timeout elapses; or register an async pattern notification with background:true.",
32017
- promptSnippet: "Wait for or watch a background bash task",
32018
- parameters: BashWatchParams,
32019
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32020
- const bridge = bridgeFor(ctx, extCtx.cwd);
32021
- const waitFor = parseWaitPattern(params.pattern);
32022
- if (params.background === true) {
32023
- if (!waitFor) {
32024
- throw new Error("invalid_request: Use auto-reminder; bash_watch without pattern in async mode is redundant");
32025
- }
32026
- const notifyParams = {
32027
- task_id: params.task_id,
32028
- once: params.once !== false
32029
- };
32030
- if (waitFor.kind === "regex")
32031
- notifyParams.regex = waitFor.source;
32032
- else
32033
- notifyParams.pattern = waitFor.value;
32034
- const sessionId = resolveSessionId(extCtx);
32035
- markExplicitControl(sessionId, params.task_id, false);
32036
- let registered;
32037
- try {
32038
- registered = await callBridge(bridge, "bash_notify", notifyParams, extCtx);
32039
- } catch (err) {
32040
- unmarkExplicitControl(sessionId, params.task_id);
32041
- throw err;
32042
- }
32043
- if (registered.success === false) {
32044
- unmarkExplicitControl(sessionId, params.task_id);
32045
- const message = String(registered.message ?? "bash_notify failed");
32046
- throw new Error(`${String(registered.code ?? "invalid_request")}: ${message}`);
32047
- }
32048
- markExplicitControl(sessionId, params.task_id);
32049
- const watchDetails = { registered: true, watchId: registered.watch_id };
32050
- return textResult(`Watch registered: ${registered.watch_id} on task ${params.task_id}
32051
- A notification will fire when the pattern matches or the task exits.`, watchDetails);
32052
- }
32053
- const data = await waitForBashStatus(ctx, bridge, extCtx, params.task_id, undefined, waitFor, true, Math.min(coerceOptionalInt(params.timeout_ms, "timeout_ms", 1, MAX_BASH_STATUS_WAIT_TIMEOUT_MS) ?? DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS, MAX_BASH_STATUS_WAIT_TIMEOUT_MS));
32054
- const text = await formatBashStatus(extCtx, params.task_id, data, undefined);
32055
- return textResult(text, data);
32056
- }
32057
- };
31989
+ function formatReadFooter(agentSpecifiedRange, data) {
31990
+ if (agentSpecifiedRange)
31991
+ return "";
31992
+ if (!data.truncated)
31993
+ return "";
31994
+ const startLine = data.start_line;
31995
+ const endLine = data.end_line;
31996
+ const totalLines = data.total_lines;
31997
+ if (startLine === undefined || endLine === undefined || totalLines === undefined) {
31998
+ return "";
31999
+ }
32000
+ return `
32001
+ (Showing lines ${startLine}-${endLine} of ${totalLines}. Use offset/limit to read other sections.)`;
32058
32002
  }
32059
- function createBashWriteTool(ctx) {
32060
- return {
32061
- name: "bash_write",
32062
- label: "bash_write",
32063
- description: 'Write input bytes to a running PTY bash task. PTY-only; check bash_status reports mode: "pty" first. ' + 'Input is either a string (verbatim bytes) or an array mixing strings and { key: "esc" | "enter" | "up" | "ctrl-c" | ... } objects ' + 'for atomic text+key sequences such as [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ]. ' + "Named keys cover enter/return/tab/space/backspace/esc/escape, arrows, home/end/page-up/page-down/delete/insert, f1..f12, and ctrl-a..ctrl-z. " + "Maximum 1 MiB per call (post-expansion).",
32064
- promptSnippet: "Write keystrokes/input to a PTY bash task",
32065
- parameters: BashWriteParams,
32066
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32067
- const bridge = bridgeFor(ctx, extCtx.cwd);
32068
- const data = await callBridge(bridge, "bash_write", { task_id: params.task_id, input: params.input }, extCtx);
32069
- return textResult(JSON.stringify({ bytes_written: data.bytes_written }, null, 2), data);
32070
- }
32071
- };
32003
+
32004
+ // src/tools/render-helpers.ts
32005
+ import { homedir as homedir9 } from "node:os";
32006
+ import { renderDiff as renderDiff2 } from "@earendil-works/pi-coding-agent";
32007
+ import { Container as Container2, Spacer as Spacer2, Text as Text2 } from "@earendil-works/pi-tui";
32008
+ function reuseText2(last) {
32009
+ return last instanceof Text2 ? last : new Text2("", 0, 0);
32072
32010
  }
32073
- function createBashKillTool(ctx) {
32074
- return {
32075
- name: "bash_kill",
32076
- label: "bash_kill",
32077
- description: "Terminate a running background bash task spawned with bash({ background: true }).",
32078
- promptSnippet: "Kill a background bash task by task_id",
32079
- parameters: BashTaskParams,
32080
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32081
- const bridge = bridgeFor(ctx, extCtx.cwd);
32082
- const data = await callBridge(bridge, "bash_kill", { task_id: params.task_id }, extCtx);
32083
- if (data.success === false) {
32084
- throw new Error(data.message ?? "bash_kill failed");
32085
- }
32086
- await disposePtyTerminal(ptyCacheKey(extCtx, params.task_id));
32087
- const details = data;
32088
- if (details.kill_signaled === true) {
32089
- return bashKillResult(`Task ${params.task_id}: kill_signaled`, details);
32090
- }
32091
- return bashKillResult(`Task ${params.task_id}: ${details.status}`, details);
32092
- }
32093
- };
32011
+ function reuseContainer2(last) {
32012
+ return last instanceof Container2 ? last : new Container2;
32094
32013
  }
32095
- function bashResult(output, details) {
32096
- return {
32097
- content: [{ type: "text", text: output }],
32098
- details: {
32099
- exit_code: details.exit_code,
32100
- duration_ms: details.duration_ms,
32101
- truncated: details.truncated,
32102
- output_path: details.output_path,
32103
- task_id: details.task_id,
32104
- bg_completions: details.bg_completions
32105
- }
32106
- };
32014
+ function shortenPath2(path2) {
32015
+ const home = homedir9();
32016
+ if (path2.startsWith(home))
32017
+ return `~${path2.slice(home.length)}`;
32018
+ return path2;
32107
32019
  }
32108
- function bashStatusResult(output, details) {
32109
- return {
32110
- content: [{ type: "text", text: output }],
32111
- details
32112
- };
32020
+ function renderToolCall(toolName, summary, theme, context) {
32021
+ const text = reuseText2(context.lastComponent);
32022
+ const suffix = summary ? ` ${summary}` : "";
32023
+ text.setText(`${theme.fg("toolTitle", theme.bold(toolName))}${suffix}`);
32024
+ return text;
32113
32025
  }
32114
- function bashKillResult(output, details) {
32115
- return {
32116
- content: [{ type: "text", text: output }],
32117
- details
32118
- };
32026
+ function accentPath(theme, path2) {
32027
+ if (!path2)
32028
+ return theme.fg("toolOutput", "...");
32029
+ return theme.fg("accent", shortenPath2(path2));
32119
32030
  }
32120
- async function bashStatusSnapshot(bridge, extCtx, taskId, outputMode, options) {
32121
- return await callBridge(bridge, "bash_status", { task_id: taskId, output_mode: outputMode }, extCtx, options);
32031
+ function collectTextContent(result) {
32032
+ return result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join(`
32033
+ `).trim();
32122
32034
  }
32123
- async function waitForBashStatus(ctx, bridge, extCtx, taskId, outputMode, waitFor, waitForExit, effectiveWaitMs) {
32124
- const startedAt = Date.now();
32125
- const deadline = startedAt + effectiveWaitMs;
32126
- let spillCursor = { output: 0, stderr: 0, combined: 0 };
32127
- let scanText = "";
32128
- let scanBaseOffset = 0;
32129
- const bridgeOptions = {
32130
- keepBridgeOnTimeout: true,
32131
- transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS
32132
- };
32133
- const sessionId = resolveSessionId(extCtx);
32134
- if (waitForExit)
32135
- markTaskWaiting(sessionId, taskId);
32136
- let sawTerminal = false;
32035
+ function extractStructuredPayload(result) {
32036
+ if (result.details !== undefined)
32037
+ return result.details;
32038
+ const text = collectTextContent(result);
32039
+ if (!text)
32040
+ return;
32137
32041
  try {
32138
- while (true) {
32139
- const data = await bashStatusSnapshot(bridge, extCtx, taskId, outputMode, bridgeOptions);
32140
- const terminal = isTerminalStatus(data.status);
32141
- if (waitFor) {
32142
- const scan = await readNewTaskOutput(extCtx, taskId, data, spillCursor);
32143
- if (scan) {
32144
- spillCursor = scan.nextCursor;
32145
- if (scanText.length === 0)
32146
- scanBaseOffset = scan.baseOffset;
32147
- scanText += scan.text;
32148
- const match = findWaitMatch(scanText, waitFor);
32149
- if (match) {
32150
- if (waitForExit && terminal) {
32151
- sawTerminal = true;
32152
- consumeBgCompletion(sessionId, taskId);
32153
- await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32154
- }
32155
- return withWaited(data, {
32156
- reason: "matched",
32157
- elapsed_ms: Date.now() - startedAt,
32158
- match: match.text,
32159
- match_offset: scanBaseOffset + Buffer.byteLength(scanText.slice(0, match.index), "utf8")
32160
- });
32161
- }
32162
- }
32163
- }
32164
- if (terminal) {
32165
- if (waitForExit) {
32166
- sawTerminal = true;
32167
- consumeBgCompletion(sessionId, taskId);
32168
- await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32169
- }
32170
- return withWaited(data, { reason: "exited", elapsed_ms: Date.now() - startedAt });
32171
- }
32172
- if (Date.now() >= deadline) {
32173
- return withWaited(data, { reason: "timeout", elapsed_ms: Date.now() - startedAt });
32174
- }
32175
- await sleep(Math.min(BASH_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
32176
- }
32177
- } finally {
32178
- if (waitForExit && !sawTerminal)
32179
- unmarkTaskWaiting(sessionId, taskId);
32180
- if (waitFor) {
32181
- await disposePtyTerminal(watchPtyCacheKey(extCtx, taskId));
32182
- }
32042
+ return JSON.parse(text);
32043
+ } catch {
32044
+ return;
32183
32045
  }
32184
32046
  }
32185
- async function readNewTaskOutput(extCtx, taskId, data, cursor) {
32186
- const outputPath = data.output_path;
32187
- if (data.mode === "pty") {
32188
- if (!outputPath)
32189
- return;
32190
- const { rows, cols } = ptyDimensions(data);
32191
- const state = await getOrCreatePtyTerminal(watchPtyCacheKey(extCtx, taskId), outputPath, rows, cols);
32192
- const baseOffset = state.offset;
32193
- const bytes = await readPtyBytes(state);
32194
- if (bytes.length === 0)
32195
- return;
32196
- return {
32197
- text: bytes.toString("utf8"),
32198
- baseOffset,
32199
- nextCursor: { output: state.offset, stderr: 0, combined: state.offset }
32200
- };
32201
- }
32202
- const stderrPath = data.stderr_path;
32203
- if (!outputPath && !stderrPath)
32204
- return;
32205
- const stdoutBytes = outputPath ? await readFileBytesFrom(outputPath, cursor.output) : Buffer.alloc(0);
32206
- const stderrBytes = stderrPath ? await readFileBytesFrom(stderrPath, cursor.stderr) : Buffer.alloc(0);
32207
- const bytesRead = stdoutBytes.length + stderrBytes.length;
32208
- if (bytesRead === 0)
32209
- return;
32210
- return {
32211
- text: Buffer.concat([stdoutBytes, stderrBytes]).toString("utf8"),
32212
- baseOffset: cursor.combined,
32213
- nextCursor: {
32214
- output: cursor.output + stdoutBytes.length,
32215
- stderr: cursor.stderr + stderrBytes.length,
32216
- combined: cursor.combined + bytesRead
32217
- }
32218
- };
32047
+ function renderErrorResult(result, fallback, theme, context) {
32048
+ const text = reuseText2(context.lastComponent);
32049
+ const message = collectTextContent(result) || fallback;
32050
+ text.setText(`
32051
+ ${theme.fg("error", message)}`);
32052
+ return text;
32219
32053
  }
32220
- async function readFileBytesFrom(outputPath, cursor) {
32221
- const handle = await fs3.open(outputPath, "r");
32222
- try {
32223
- const chunks = [];
32224
- let offset = cursor;
32225
- while (true) {
32226
- const buffer2 = Buffer.allocUnsafe(64 * 1024);
32227
- const { bytesRead } = await handle.read(buffer2, 0, buffer2.length, offset);
32228
- if (bytesRead === 0)
32229
- break;
32230
- chunks.push(Buffer.from(buffer2.subarray(0, bytesRead)));
32231
- offset += bytesRead;
32232
- }
32233
- return Buffer.concat(chunks);
32234
- } finally {
32235
- await handle.close().catch(() => {
32236
- return;
32237
- });
32238
- }
32054
+ function renderSections(sections, context) {
32055
+ const container = reuseContainer2(context.lastComponent);
32056
+ container.clear();
32057
+ const visibleSections = sections.filter((section) => section.trim().length > 0);
32058
+ if (visibleSections.length === 0)
32059
+ return container;
32060
+ container.addChild(new Spacer2(1));
32061
+ visibleSections.forEach((section, index) => {
32062
+ if (index > 0)
32063
+ container.addChild(new Spacer2(1));
32064
+ container.addChild(new Text2(section, 0, 0));
32065
+ });
32066
+ return container;
32239
32067
  }
32240
- function parseWaitPattern(value) {
32241
- if (typeof value === "string")
32242
- return { kind: "substring", value };
32243
- if (isRegexWaitObject(value))
32244
- return { kind: "regex", value: new RegExp(value.regex), source: value.regex };
32245
- return;
32068
+ function asRecord2(value) {
32069
+ if (!value || typeof value !== "object" || Array.isArray(value))
32070
+ return;
32071
+ return value;
32246
32072
  }
32247
- function isRegexWaitObject(value) {
32248
- return typeof value === "object" && value !== null && "regex" in value && typeof value.regex === "string";
32073
+ function asRecords(value) {
32074
+ return Array.isArray(value) ? value.map(asRecord2).filter(Boolean) : [];
32249
32075
  }
32250
- function findWaitMatch(text, pattern) {
32251
- if (pattern.kind === "substring") {
32252
- const index = text.indexOf(pattern.value);
32253
- return index >= 0 ? { text: pattern.value, index } : undefined;
32254
- }
32255
- pattern.value.lastIndex = 0;
32256
- const match = pattern.value.exec(text);
32257
- return match ? { text: match[0], index: match.index } : undefined;
32076
+ function asString(value) {
32077
+ return typeof value === "string" ? value : undefined;
32258
32078
  }
32259
- function withWaited(data, waited) {
32260
- return { ...data, waited };
32079
+ function asNumber(value) {
32080
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
32261
32081
  }
32262
- function formatWaitSummary(waited, details) {
32263
- if (waited.reason === "matched") {
32264
- return `Waited ${waited.elapsed_ms}ms; matched ${JSON.stringify(waited.match ?? "")} at offset ${waited.match_offset ?? 0}.`;
32265
- }
32266
- if (waited.reason === "timeout") {
32267
- return `Waited ${waited.elapsed_ms}ms; timeout reached without match.`;
32082
+ function asBoolean(value) {
32083
+ return typeof value === "boolean" ? value : undefined;
32084
+ }
32085
+ function formatValue(value) {
32086
+ if (Array.isArray(value))
32087
+ return value.map(formatValue).join(", ");
32088
+ if (typeof value === "string")
32089
+ return value;
32090
+ if (typeof value === "number" || typeof value === "boolean")
32091
+ return String(value);
32092
+ if (value === null || value === undefined)
32093
+ return "—";
32094
+ try {
32095
+ return JSON.stringify(value);
32096
+ } catch {
32097
+ return String(value);
32268
32098
  }
32269
- const exit = typeof details.exit_code === "number" ? `, exit ${details.exit_code}` : "";
32270
- return `Waited ${waited.elapsed_ms}ms; task exited (${details.status}${exit}).`;
32271
32099
  }
32272
- async function formatBashStatus(extCtx, taskId, details, requestedOutputMode) {
32273
- const exit = typeof details.exit_code === "number" ? ` (exit ${details.exit_code})` : "";
32274
- const dur = typeof details.duration_ms === "number" ? ` ${Math.round(details.duration_ms / 1000)}s` : "";
32275
- let text = `Task ${taskId}: ${details.status}${exit}${dur}`;
32276
- if (details.waited)
32277
- text += `
32278
- ${formatWaitSummary(details.waited, details)}`;
32279
- if (details.mode === "pty") {
32280
- text += await formatPtyStatus(extCtx, taskId, details, requestedOutputMode);
32281
- } else {
32282
- if (isTerminalStatus(details.status) && details.output_preview) {
32283
- text += `
32284
- ${details.output_preview.slice(0, 2000)}`;
32100
+ function groupByFile(items, getFile) {
32101
+ const groups = new Map;
32102
+ items.forEach((item) => {
32103
+ const file2 = getFile(item) ?? "(unknown file)";
32104
+ const current = groups.get(file2) ?? [];
32105
+ current.push(item);
32106
+ groups.set(file2, current);
32107
+ });
32108
+ return groups;
32109
+ }
32110
+ function formatTimestamp(value) {
32111
+ if (typeof value === "string" && value.length > 0)
32112
+ return value;
32113
+ if (typeof value !== "number" || !Number.isFinite(value))
32114
+ return;
32115
+ const millis = value > 1000000000000 ? value : value * 1000;
32116
+ const date5 = new Date(millis);
32117
+ if (Number.isNaN(date5.getTime()))
32118
+ return String(value);
32119
+ return date5.toISOString().replace("T", " ").replace(".000Z", "Z");
32120
+ }
32121
+ function formatUnifiedDiffForPi(unifiedDiff) {
32122
+ if (!unifiedDiff.trim())
32123
+ return "";
32124
+ const entries = [];
32125
+ const hunkHeader = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
32126
+ let oldLine = 1;
32127
+ let newLine = 1;
32128
+ for (const line of unifiedDiff.split(`
32129
+ `)) {
32130
+ if (!line)
32131
+ continue;
32132
+ if (line.startsWith("--- ") || line.startsWith("+++ "))
32133
+ continue;
32134
+ if (line.startsWith("\"))
32135
+ continue;
32136
+ const headerMatch = line.match(hunkHeader);
32137
+ if (headerMatch) {
32138
+ oldLine = Number(headerMatch[1]);
32139
+ newLine = Number(headerMatch[2]);
32140
+ continue;
32285
32141
  }
32286
- if (!isTerminalStatus(details.status)) {
32287
- text += `
32288
- A completion reminder will be delivered automatically; don't poll.`;
32142
+ if (line.startsWith("+") && !line.startsWith("+++")) {
32143
+ entries.push({ prefix: "+", line: newLine, text: line.slice(1) });
32144
+ newLine += 1;
32145
+ continue;
32146
+ }
32147
+ if (line.startsWith("-") && !line.startsWith("---")) {
32148
+ entries.push({ prefix: "-", line: oldLine, text: line.slice(1) });
32149
+ oldLine += 1;
32150
+ continue;
32151
+ }
32152
+ if (line.startsWith(" ")) {
32153
+ entries.push({ prefix: " ", line: oldLine, text: line.slice(1) });
32154
+ oldLine += 1;
32155
+ newLine += 1;
32289
32156
  }
32290
32157
  }
32291
- return text;
32158
+ if (entries.length === 0)
32159
+ return "";
32160
+ const width = String(entries.reduce((max, entry) => Math.max(max, entry.line), 1)).length;
32161
+ return entries.map((entry) => `${entry.prefix}${String(entry.line).padStart(width, " ")} ${entry.text}`).join(`
32162
+ `);
32292
32163
  }
32293
- async function formatPtyStatus(extCtx, taskId, details, requestedOutputMode) {
32294
- if (!details.output_path)
32295
- return `
32296
- [PTY output path unavailable]`;
32297
- const key = ptyCacheKey(extCtx, taskId);
32298
- const { rows, cols } = ptyDimensions(details);
32299
- const state = await getOrCreatePtyTerminal(key, details.output_path, rows, cols);
32300
- const raw = await readPtyBytes(state);
32301
- const outputMode = requestedOutputMode ?? "screen";
32302
- let suffix = "";
32303
- if (outputMode === "raw") {
32304
- suffix = raw.length > 0 ? `
32305
- ${raw.toString("utf8")}` : "";
32306
- } else if (outputMode === "both") {
32307
- suffix = `
32308
- ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
32309
- } else {
32310
- const screen = renderScreen(state, rows, cols);
32311
- suffix = screen ? `
32312
- ${screen}` : "";
32313
- }
32314
- if (!isTerminalStatus(details.status)) {
32315
- suffix += `
32316
- PTY task is still running. Use bash_status({ task_id: "${taskId}", output_mode: "screen" }) to inspect, bash_write({ task_id: "${taskId}", input: "..." }) to send keystrokes.`;
32317
- } else {
32318
- await disposePtyTerminal(key);
32164
+ function renderUnifiedDiff(unifiedDiff) {
32165
+ const piDiff = formatUnifiedDiffForPi(unifiedDiff);
32166
+ if (!piDiff)
32167
+ return "";
32168
+ try {
32169
+ return renderDiff2(piDiff);
32170
+ } catch {
32171
+ return piDiff;
32319
32172
  }
32320
- return suffix;
32321
- }
32322
- function ptyDimensions(data) {
32323
- const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
32324
- const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
32325
- return { rows, cols };
32326
- }
32327
- function ptyCacheKey(extCtx, taskId) {
32328
- return `${extCtx.cwd}::${resolveSessionId(extCtx) ?? "__default__"}::${taskId}`;
32329
32173
  }
32330
- function watchPtyCacheKey(extCtx, taskId) {
32331
- return `${ptyCacheKey(extCtx, taskId)}::watch`;
32174
+
32175
+ // src/tools/ast.ts
32176
+ var AstLang = StringEnum(["typescript", "tsx", "javascript", "python", "rust", "go"], {
32177
+ description: "Target language"
32178
+ });
32179
+ var SearchParams = Type3.Object({
32180
+ pattern: Type3.String({
32181
+ description: "AST pattern with meta-variables (`$VAR` matches one node, `$$$` matches many). Must be a complete AST node."
32182
+ }),
32183
+ lang: AstLang,
32184
+ paths: Type3.Optional(Type3.Array(Type3.String(), { description: "Paths to search (default: ['.'])" })),
32185
+ globs: Type3.Optional(Type3.Array(Type3.String(), { description: "Include/exclude globs (prefix `!` to exclude)" })),
32186
+ contextLines: Type3.Optional(Type3.Number({ description: "Number of context lines around each match" }))
32187
+ });
32188
+ var ReplaceParams = Type3.Object({
32189
+ pattern: Type3.String({ description: "AST pattern with meta-variables" }),
32190
+ rewrite: Type3.String({ description: "Replacement pattern, can reference $VAR from pattern" }),
32191
+ lang: AstLang,
32192
+ paths: Type3.Optional(Type3.Array(Type3.String(), { description: "Paths (default: ['.'])" })),
32193
+ globs: Type3.Optional(Type3.Array(Type3.String(), { description: "Include/exclude globs" })),
32194
+ dryRun: Type3.Optional(Type3.Boolean({ description: "Preview without applying (default: false)" }))
32195
+ });
32196
+ function appendHintSection(response, sections, theme) {
32197
+ const hint = asString(response.hint);
32198
+ if (hint && hint.length > 0) {
32199
+ sections.push(theme.fg("warning", hint));
32200
+ }
32332
32201
  }
32333
- function isTerminalStatus(status) {
32334
- return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
32202
+ function appendScopeSections(response, sections, theme) {
32203
+ if (response.no_files_matched_scope === true) {
32204
+ sections.push(theme.fg("warning", "No files matched the scope (paths/globs resolved to zero files)"));
32205
+ }
32206
+ const warnings = asRecords(response.scope_warnings);
32207
+ const warningStrings = Array.isArray(response.scope_warnings) ? response.scope_warnings.filter((w) => typeof w === "string") : warnings.map((w) => asString(w.warning) ?? "").filter(Boolean);
32208
+ if (warningStrings.length > 0) {
32209
+ sections.push(`${theme.fg("muted", "Scope warnings:")}
32210
+ ${warningStrings.map((w) => ` ${w}`).join(`
32211
+ `)}`);
32212
+ }
32335
32213
  }
32336
- function renderBashCall(command, description, theme, context) {
32337
- const text = reuseText2(context.lastComponent);
32338
- const display = description ?? (command ? shortenCommand(command) : "...");
32339
- text.setText(`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", display)}`);
32340
- return text;
32214
+ async function resolveAstPaths(extCtx, paths) {
32215
+ if (isEmptyParam(paths) || !Array.isArray(paths))
32216
+ return;
32217
+ return Promise.all(paths.filter((path2) => typeof path2 === "string").map((path2) => resolvePathArg(extCtx.cwd, path2)));
32341
32218
  }
32342
- function renderBashResult(result, theme, context) {
32343
- if (context.isError) {
32344
- const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
32345
- `).trim();
32346
- const text = reuseText2(context.lastComponent);
32347
- text.setText(`
32348
- ${theme.fg("error", errorText || "bash failed")}`);
32349
- return text;
32350
- }
32351
- const details = result.details;
32352
- const exitCode = details?.exit_code;
32353
- const bgCompletions = details?.bg_completions ?? [];
32354
- const container = reuseContainer2(context.lastComponent);
32355
- container.clear();
32356
- container.addChild(new Spacer2(1));
32357
- const rawOutput = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
32358
- `).trim();
32359
- if (rawOutput) {
32360
- const lines = rawOutput.split(`
32361
- `);
32362
- const preview = lines.length > 25 ? `... (${lines.length - 25} lines omitted)
32363
- ${lines.slice(-25).join(`
32364
- `)}` : rawOutput;
32365
- container.addChild(new Text2(preview, 1, 0));
32366
- container.addChild(new Spacer2(1));
32219
+ async function assertAstPathsPermission(extCtx, paths, action, restrictToProjectRoot) {
32220
+ if (paths === undefined || paths.length === 0)
32221
+ return;
32222
+ const checked = new Set;
32223
+ for (const path2 of paths) {
32224
+ if (checked.has(path2))
32225
+ continue;
32226
+ checked.add(path2);
32227
+ await assertExternalDirectoryPermission(extCtx, path2, action, { restrictToProjectRoot });
32367
32228
  }
32368
- if (exitCode !== undefined) {
32369
- const exitColor = exitCode === 0 ? "success" : "error";
32370
- const exitText = theme.fg(exitColor, `exit ${exitCode}`);
32371
- container.addChild(new Text2(exitText, 1, 0));
32372
- }
32373
- if (bgCompletions.length > 0) {
32374
- container.addChild(new Spacer2(1));
32375
- for (const bg of bgCompletions) {
32376
- const cmdPreview = bg.command ? bg.command.slice(0, 60) : "unknown command";
32377
- const suffix = (bg.command?.length ?? 0) > 60 ? "..." : "";
32378
- const exitInfo = bg.exit_code !== undefined ? `exit ${bg.exit_code}` : bg.status;
32379
- const statusColor = bg.status === "completed" && bg.exit_code === 0 ? "success" : "warning";
32380
- const line = theme.fg(statusColor, `Background task ${bg.task_id} completed (${exitInfo}): ${cmdPreview}${suffix}`);
32381
- container.addChild(new Text2(line, 1, 0));
32382
- }
32383
- }
32384
- if (details?.duration_ms !== undefined) {
32385
- container.addChild(new Spacer2(1));
32386
- const durationText = theme.fg("muted", `${details.duration_ms}ms`);
32387
- container.addChild(new Text2(durationText, 1, 0));
32229
+ }
32230
+ function buildAstSearchSections(payload, theme) {
32231
+ const response = asRecord2(payload);
32232
+ if (!response)
32233
+ return [theme.fg("muted", "No AST search results.")];
32234
+ const matches = asRecords(response.matches);
32235
+ const totalMatches = asNumber(response.total_matches) ?? matches.length;
32236
+ const filesWithMatches = asNumber(response.files_with_matches) ?? groupByFile(matches, (match) => asString(match.file)).size;
32237
+ const filesSearched = asNumber(response.files_searched);
32238
+ const header = [
32239
+ theme.fg("success", `${totalMatches} match${totalMatches === 1 ? "" : "es"}`),
32240
+ theme.fg("accent", `${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`),
32241
+ filesSearched !== undefined ? theme.fg("muted", `${filesSearched} searched`) : undefined
32242
+ ].filter(Boolean).join(" · ");
32243
+ if (matches.length === 0) {
32244
+ const sections2 = [header, theme.fg("muted", "No AST matches found.")];
32245
+ appendScopeSections(response, sections2, theme);
32246
+ appendHintSection(response, sections2, theme);
32247
+ return sections2;
32388
32248
  }
32389
- if (details?.truncated) {
32390
- container.addChild(new Spacer2(1));
32391
- const truncText = theme.fg("warning", "(output truncated)");
32392
- container.addChild(new Text2(truncText, 1, 0));
32249
+ const grouped = groupByFile(matches, (match) => asString(match.file));
32250
+ const sections = [header];
32251
+ for (const [file2, fileMatches] of grouped.entries()) {
32252
+ const lines = [theme.fg("accent", shortenPath2(file2))];
32253
+ fileMatches.forEach((match, index) => {
32254
+ const line = asNumber(match.line) ?? 0;
32255
+ const column = asNumber(match.column) ?? 0;
32256
+ const snippet = asString(match.text)?.trim() || "(empty match)";
32257
+ lines.push(` ${index + 1}. ${theme.fg("muted", `${line}:${column}`)} ${snippet}`);
32258
+ const metaVars = asRecord2(match.meta_variables);
32259
+ if (metaVars && Object.keys(metaVars).length > 0) {
32260
+ Object.entries(metaVars).forEach(([name, value]) => {
32261
+ lines.push(` ${theme.fg("muted", `${name} =`)} ${formatValue(value)}`);
32262
+ });
32263
+ }
32264
+ const context = asRecords(match.context);
32265
+ context.forEach((ctxLine) => {
32266
+ const ctxNumber = asNumber(ctxLine.line) ?? 0;
32267
+ const prefix = ctxLine.is_match === true ? theme.fg("accent", ">") : theme.fg("muted", "|");
32268
+ lines.push(` ${prefix} ${ctxNumber}: ${asString(ctxLine.text) ?? ""}`);
32269
+ });
32270
+ });
32271
+ sections.push(lines.join(`
32272
+ `));
32393
32273
  }
32394
- return container;
32395
- }
32396
- function shortenCommand(command) {
32397
- if (command.length <= 60)
32398
- return command;
32399
- return `${command.slice(0, 57)}...`;
32400
- }
32401
-
32402
- // src/tools/conflicts.ts
32403
- import { Type as Type4 } from "typebox";
32404
- var ConflictsParams = Type4.Object({});
32405
- function renderConflictCall(theme, context) {
32406
- return renderToolCall("conflicts", undefined, theme, context);
32274
+ return sections;
32407
32275
  }
32408
- function buildConflictSections(text) {
32409
- const trimmed = text.trim();
32410
- if (!trimmed)
32411
- return ["No merge conflicts found."];
32412
- const [header, ...rest] = trimmed.split(/\n\n+/);
32413
- const match = header.match(/^(\d+)\s+files?,\s+(\d+)\s+conflicts?/i);
32414
- const sections = [
32415
- match ? `${match[1]} conflicted file${match[1] === "1" ? "" : "s"} · ${match[2]} region${match[2] === "1" ? "" : "s"}` : header
32276
+ function buildAstReplaceSections(payload, theme) {
32277
+ const response = asRecord2(payload);
32278
+ if (!response)
32279
+ return [theme.fg("muted", "No AST replace results.")];
32280
+ const files = asRecords(response.files);
32281
+ const totalReplacements = asNumber(response.total_replacements) ?? 0;
32282
+ const totalFiles = asNumber(response.total_files) ?? files.length;
32283
+ const filesWithMatches = asNumber(response.files_with_matches);
32284
+ const dryRun = response.dry_run === true;
32285
+ const headerParts = [
32286
+ dryRun ? theme.fg("warning", "[dry run]") : theme.fg("success", "[applied]"),
32287
+ `${totalReplacements} replacement${totalReplacements === 1 ? "" : "s"}`,
32288
+ `${totalFiles} file${totalFiles === 1 ? "" : "s"}`,
32289
+ filesWithMatches !== undefined ? theme.fg("muted", `${filesWithMatches} matched`) : undefined
32416
32290
  ];
32417
- if (rest.length === 0)
32291
+ const sections = [headerParts.filter(Boolean).join(" ")];
32292
+ if (files.length === 0) {
32293
+ sections.push(theme.fg("muted", "No files changed."));
32294
+ appendScopeSections(response, sections, theme);
32295
+ appendHintSection(response, sections, theme);
32418
32296
  return sections;
32419
- sections.push(...rest.map((section) => section.trim()).filter(Boolean));
32420
- return sections;
32421
- }
32422
- function renderConflictResult(text, theme, context) {
32423
- const sections = buildConflictSections(text).map((section, index) => index === 0 ? theme.fg("warning", section) : section);
32424
- return renderSections(sections, context);
32425
- }
32426
- function renderConflictToolResult(result, theme, context) {
32427
- if (context.isError)
32428
- return renderErrorResult(result, "conflicts failed", theme, context);
32429
- return renderConflictResult(collectTextContent(result), theme, context);
32430
- }
32431
- function registerConflictsTool(pi, ctx) {
32432
- pi.registerTool({
32433
- name: "aft_conflicts",
32434
- label: "conflicts",
32435
- description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call.",
32436
- parameters: ConflictsParams,
32437
- async execute(_toolCallId, _params, _signal, _onUpdate, extCtx) {
32438
- const bridge = bridgeFor(ctx, extCtx.cwd);
32439
- const response = await callBridge(bridge, "git_conflicts", {}, extCtx);
32440
- return textResult(response.text ?? JSON.stringify(response, null, 2));
32441
- },
32442
- renderCall(_args, theme, context) {
32443
- return renderConflictCall(theme, context);
32444
- },
32445
- renderResult(result, _options, theme, context) {
32446
- return renderConflictToolResult(result, theme, context);
32297
+ }
32298
+ files.forEach((fileResult) => {
32299
+ const file2 = shortenPath2(asString(fileResult.file) ?? "(unknown file)");
32300
+ const replacements = asNumber(fileResult.replacements) ?? 0;
32301
+ const error50 = asString(fileResult.error);
32302
+ const diff = asString(fileResult.diff);
32303
+ const lines = [
32304
+ `${theme.fg("accent", file2)} ${theme.fg("muted", `(${replacements} replacement${replacements === 1 ? "" : "s"})`)}`
32305
+ ];
32306
+ if (error50) {
32307
+ lines.push(theme.fg("error", error50));
32308
+ } else if (diff) {
32309
+ const rendered = renderUnifiedDiff(diff);
32310
+ lines.push(rendered || theme.fg("muted", "No diff available."));
32311
+ } else {
32312
+ const backupId = asString(fileResult.backup_id);
32313
+ lines.push(backupId ? `${theme.fg("success", "saved")} ${theme.fg("muted", backupId)}` : theme.fg("success", "saved"));
32447
32314
  }
32315
+ sections.push(lines.join(`
32316
+ `));
32448
32317
  });
32318
+ return sections;
32449
32319
  }
32450
-
32451
- // src/tools/fs.ts
32452
- import { Type as Type5 } from "typebox";
32453
- var DeleteParams = Type5.Object({
32454
- files: Type5.Array(Type5.String(), {
32455
- description: "Paths to delete (one or more). May include directories when recursive=true.",
32456
- minItems: 1
32457
- }),
32458
- recursive: Type5.Optional(Type5.Boolean({
32459
- description: "Required to delete a directory and its contents. Defaults to false; passing a directory without this returns an error."
32460
- }))
32461
- });
32462
- var MoveParams = Type5.Object({
32463
- filePath: Type5.String({
32464
- description: "Source file path to move (absolute or relative to project root)"
32465
- }),
32466
- destination: Type5.String({
32467
- description: "Destination file path (absolute or relative to project root)"
32468
- })
32469
- });
32470
- function renderFsCall(toolName, args, theme, context) {
32471
- if (toolName === "aft_delete") {
32472
- const files = args.files;
32473
- const summary = files.length === 1 ? accentPath(theme, files[0]) : `${theme.fg("accent", String(files.length))} ${theme.fg("muted", "files")}`;
32474
- return renderToolCall("delete", summary, theme, context);
32475
- }
32476
- const moveArgs = args;
32477
- return renderToolCall("move", `${accentPath(theme, moveArgs.filePath)} ${theme.fg("muted", "→")} ${accentPath(theme, moveArgs.destination)}`, theme, context);
32320
+ function renderAstCall(toolName, args, theme, context) {
32321
+ const lang = theme.fg("accent", args.lang);
32322
+ const summary = toolName === "ast_grep_replace" ? `${lang} ${theme.fg("toolOutput", `${args.pattern} ${args.rewrite}`)}` : `${lang} ${theme.fg("toolOutput", args.pattern)}`;
32323
+ return renderToolCall(toolName === "ast_grep_replace" ? "ast replace" : "ast search", summary, theme, context);
32478
32324
  }
32479
- function renderFsResult(toolName, args, result, theme, context) {
32325
+ function renderAstResult(toolName, result, theme, context) {
32480
32326
  if (context.isError) {
32481
32327
  return renderErrorResult(result, `${toolName} failed`, theme, context);
32482
32328
  }
32483
- if (toolName === "aft_delete") {
32484
- const files = args.files;
32485
- const data = result?.details ?? {};
32486
- const deletedPaths = data.deleted ?? files;
32487
- const skipped = data.skipped_files ?? [];
32488
- const lines = [];
32489
- for (const entry of deletedPaths) {
32490
- lines.push(`${theme.fg("success", "✓ deleted")} ${theme.fg("accent", shortenPath(entry))}`);
32491
- }
32492
- for (const entry of skipped) {
32493
- lines.push(`${theme.fg("error", "✗ skipped")} ${theme.fg("accent", shortenPath(entry.file))} ${theme.fg("muted", `(${entry.reason})`)}`);
32494
- }
32495
- if (lines.length === 0) {
32496
- lines.push(theme.fg("muted", "(no files deleted)"));
32497
- }
32498
- return renderSections([lines.join(`
32499
- `)], context);
32329
+ const payload = extractStructuredPayload(result);
32330
+ if (!payload) {
32331
+ const text = collectTextContent(result);
32332
+ return renderSections([text || theme.fg("muted", "No result.")], context);
32500
32333
  }
32501
- const moveArgs = args;
32502
- return renderSections([
32503
- `${theme.fg("success", "✓ moved")} ${theme.fg("accent", shortenPath(moveArgs.filePath))}`,
32504
- `${theme.fg("muted", "to")} ${theme.fg("accent", shortenPath(moveArgs.destination))}`
32505
- ], context);
32334
+ const sections = toolName === "ast_grep_replace" ? buildAstReplaceSections(payload, theme) : buildAstSearchSections(payload, theme);
32335
+ return renderSections(sections, context);
32506
32336
  }
32507
- function registerFsTools(pi, ctx, surface) {
32508
- if (surface.delete) {
32337
+ function registerAstTools(pi, ctx, surface) {
32338
+ if (surface.astSearch) {
32509
32339
  pi.registerTool({
32510
- name: "aft_delete",
32511
- label: "delete",
32512
- description: "Delete one or more files (or directories) with backup. Each file is backed up before deletion — use `aft_safety undo` to recover any of them. " + "For directories, every file inside is individually backed up before removal. Directory deletion requires recursive: true. " + "Returns { success, complete, deleted, skipped_files }: partial success is allowed; files that fail are reported in skipped_files.",
32513
- parameters: DeleteParams,
32340
+ name: "ast_grep_search",
32341
+ label: "ast search",
32342
+ description: "Search code patterns across the filesystem using AST-aware matching. Use `$VAR` to match a single AST node, `$$$` for multiple. Pattern must be a complete, valid code fragment (include braces, params, etc.).",
32343
+ parameters: SearchParams,
32514
32344
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32345
+ const paths = await resolveAstPaths(extCtx, params.paths);
32346
+ await assertAstPathsPermission(extCtx, paths, "search", ctx.config.restrict_to_project_root ?? false);
32515
32347
  const bridge = bridgeFor(ctx, extCtx.cwd);
32516
- const response = await callBridge(bridge, "delete_file", {
32517
- files: params.files,
32518
- recursive: params.recursive === true
32519
- }, extCtx);
32520
- const deletedEntries = response.deleted ?? [];
32521
- const skipped = response.skipped_files ?? [];
32522
- const deleted = deletedEntries.map((entry) => entry.file);
32523
- if (deleted.length === 0 && skipped.length > 0) {
32524
- throw new Error(`delete failed for all ${skipped.length} file(s):
32525
- ` + skipped.map((entry) => ` ${entry.file}: ${entry.reason}`).join(`
32526
- `));
32527
- }
32528
- const summary = deleted.length === 1 && skipped.length === 0 ? `Deleted ${deleted[0]}` : `Deleted ${deleted.length}/${params.files.length} file(s)`;
32529
- return textResult(summary, {
32530
- success: true,
32531
- complete: skipped.length === 0,
32532
- deleted,
32533
- skipped_files: skipped
32534
- });
32348
+ const req = {
32349
+ pattern: params.pattern,
32350
+ lang: params.lang
32351
+ };
32352
+ if (!isEmptyParam(paths))
32353
+ req.paths = paths;
32354
+ if (!isEmptyParam(params.globs))
32355
+ req.globs = params.globs;
32356
+ if (params.contextLines !== undefined)
32357
+ req.context_lines = params.contextLines;
32358
+ const response = await callBridge(bridge, "ast_search", req, extCtx);
32359
+ return textResult(response.text ?? JSON.stringify(response));
32535
32360
  },
32536
32361
  renderCall(args, theme, context) {
32537
- return renderFsCall("aft_delete", args, theme, context);
32362
+ return renderAstCall("ast_grep_search", args, theme, context);
32538
32363
  },
32539
32364
  renderResult(result, _options, theme, context) {
32540
- return renderFsResult("aft_delete", context.args, result, theme, context);
32365
+ return renderAstResult("ast_grep_search", result, theme, context);
32541
32366
  }
32542
32367
  });
32543
32368
  }
32544
- if (surface.move) {
32369
+ if (surface.astReplace) {
32545
32370
  pi.registerTool({
32546
- name: "aft_move",
32547
- label: "move",
32548
- description: "Move or rename a file with backup. Creates parent directories for the destination automatically. This operates on files at the OS level — to relocate a code symbol between files, use `aft_refactor` with op='move' instead.",
32549
- parameters: MoveParams,
32371
+ name: "ast_grep_replace",
32372
+ label: "ast replace",
32373
+ description: "Replace code patterns across the filesystem with AST-aware rewriting. Applies by default pass `dryRun: true` to preview. Use meta-variables in `rewrite` to preserve captured content from the pattern.",
32374
+ parameters: ReplaceParams,
32550
32375
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32376
+ const paths = await resolveAstPaths(extCtx, params.paths);
32377
+ await assertAstPathsPermission(extCtx, paths, params.dryRun === true ? "search" : "modify", ctx.config.restrict_to_project_root ?? false);
32551
32378
  const bridge = bridgeFor(ctx, extCtx.cwd);
32552
- const response = await callBridge(bridge, "move_file", {
32553
- file: params.filePath,
32554
- destination: params.destination
32555
- }, extCtx);
32556
- return textResult(`Moved ${params.filePath} → ${params.destination}`, response);
32557
- },
32558
- renderCall(args, theme, context) {
32559
- return renderFsCall("aft_move", args, theme, context);
32379
+ const req = {
32380
+ pattern: params.pattern,
32381
+ rewrite: params.rewrite,
32382
+ lang: params.lang
32383
+ };
32384
+ if (!isEmptyParam(paths))
32385
+ req.paths = paths;
32386
+ if (!isEmptyParam(params.globs))
32387
+ req.globs = params.globs;
32388
+ req.dry_run = params.dryRun === true;
32389
+ const response = await callBridge(bridge, "ast_replace", req, extCtx);
32390
+ return textResult(response.text ?? JSON.stringify(response));
32391
+ },
32392
+ renderCall(args, theme, context) {
32393
+ return renderAstCall("ast_grep_replace", args, theme, context);
32560
32394
  },
32561
32395
  renderResult(result, _options, theme, context) {
32562
- return renderFsResult("aft_move", context.args, result, theme, context);
32396
+ return renderAstResult("ast_grep_replace", result, theme, context);
32563
32397
  }
32564
32398
  });
32565
32399
  }
32566
32400
  }
32567
32401
 
32568
- // src/tools/hoisted.ts
32569
- import { stat } from "node:fs/promises";
32570
- import { homedir as homedir9 } from "node:os";
32571
- import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve3, sep } from "node:path";
32572
- import {
32573
- renderDiff as renderDiff2
32574
- } from "@earendil-works/pi-coding-agent";
32402
+ // src/tools/bash.ts
32403
+ import * as fs3 from "node:fs/promises";
32575
32404
  import { Container as Container3, Spacer as Spacer3, Text as Text3 } from "@earendil-works/pi-tui";
32576
- import { Type as Type6 } from "typebox";
32577
-
32578
- // src/tools/diff-format.ts
32579
- import { diffLines } from "diff";
32580
- var DEFAULT_CONTEXT_LINES = 4;
32581
- function formatDiffForPi(oldContent, newContent, contextLines = DEFAULT_CONTEXT_LINES) {
32582
- const parts = diffLines(oldContent, newContent);
32583
- const output = [];
32584
- const oldLines = oldContent.split(`
32585
- `);
32586
- const newLines = newContent.split(`
32405
+ import { Type as Type4 } from "typebox";
32406
+ var FOREGROUND_POLL_INTERVAL_MS = 100;
32407
+ var BASH_WAIT_POLL_INTERVAL_MS = 100;
32408
+ var DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS = 30000;
32409
+ var MAX_BASH_STATUS_WAIT_TIMEOUT_MS = 300000;
32410
+ var BASH_TRANSPORT_TIMEOUT_MS = 30000;
32411
+ var BashParams = Type4.Object({
32412
+ command: Type4.String({
32413
+ description: "Shell command to execute. Supports pipes, redirections, and shell syntax."
32414
+ }),
32415
+ timeout: optionalInt(1, Number.MAX_SAFE_INTEGER),
32416
+ workdir: Type4.Optional(Type4.String({
32417
+ description: "Working directory for command execution. Relative paths resolve against the project root. Defaults to the current session's working directory."
32418
+ })),
32419
+ description: Type4.Optional(Type4.String({
32420
+ description: "Human-readable description shown in UI logs. Helps users understand what the command does without reading shell syntax."
32421
+ })),
32422
+ background: Type4.Optional(Type4.Boolean({
32423
+ description: "Spawn command in background and return immediately with a task_id. Use bash_status to poll completion and bash_kill to terminate. Ideal for long-running tasks like builds or dev servers."
32424
+ })),
32425
+ compressed: Type4.Optional(Type4.Boolean({
32426
+ description: "Compress output by removing ANSI codes, carriage returns, and excessive blank lines. Default: true. Set to false for raw terminal output including color codes."
32427
+ })),
32428
+ pty: Type4.Optional(Type4.Boolean({
32429
+ description: 'Spawn the command in a real PTY for interactive programs. Implies background: true automatically. Inspect with bash_status({ task_id, output_mode: "screen" }) and send input with bash_write.'
32430
+ })),
32431
+ ptyRows: optionalInt(1, 60),
32432
+ ptyCols: optionalInt(1, 140)
32433
+ });
32434
+ var BashTaskParams = Type4.Object({
32435
+ task_id: Type4.String({
32436
+ description: "Background bash task id returned by bash({ background: true })."
32437
+ })
32438
+ });
32439
+ var BashStatusParams = Type4.Object({
32440
+ task_id: Type4.String({
32441
+ description: "Background bash task id returned by bash({ background: true })."
32442
+ }),
32443
+ output_mode: Type4.Optional(Type4.Union([Type4.Literal("screen"), Type4.Literal("raw"), Type4.Literal("both")], {
32444
+ description: "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted."
32445
+ }))
32446
+ });
32447
+ var BashWatchParams = Type4.Object({
32448
+ task_id: Type4.String({
32449
+ description: "Background bash task id returned by bash({ background: true })."
32450
+ }),
32451
+ pattern: Type4.Optional(Type4.Union([Type4.String(), Type4.Object({ regex: Type4.String() })])),
32452
+ background: Type4.Optional(Type4.Boolean()),
32453
+ timeout_ms: optionalInt(1, MAX_BASH_STATUS_WAIT_TIMEOUT_MS),
32454
+ once: Type4.Optional(Type4.Boolean())
32455
+ });
32456
+ var BashWriteParams = Type4.Object({
32457
+ task_id: Type4.String({
32458
+ description: "Background PTY task id returned by bash({ pty: true, background: true })."
32459
+ }),
32460
+ input: Type4.Union([
32461
+ Type4.String(),
32462
+ Type4.Array(Type4.Union([
32463
+ Type4.String(),
32464
+ Type4.Object({
32465
+ key: Type4.String({
32466
+ description: "Named control key, e.g. 'esc', 'enter', 'up', 'ctrl-c'. Case-insensitive."
32467
+ })
32468
+ })
32469
+ ]))
32470
+ ], {
32471
+ description: "Either a string of verbatim bytes (e.g. 'print(1)\\n') OR an array mixing strings " + "and { key: '<name>' } objects for atomic text+key sequences. " + "Example: [ 'iHello', { key: 'esc' }, ':wq', { key: 'enter' } ]. " + "Allowed key names: enter, return, tab, space, backspace, esc, escape, up, down, " + "left, right, home, end, page-up, page-down, delete, insert, f1..f12, ctrl-a..ctrl-z."
32472
+ })
32473
+ });
32474
+ function truncateToVisualLines(text, maxLines) {
32475
+ const lines = text.split(`
32587
32476
  `);
32588
- const maxLineNum = Math.max(oldLines.length, newLines.length);
32589
- const lineNumWidth = String(maxLineNum).length;
32590
- const pad = (n) => String(n).padStart(lineNumWidth, " ");
32591
- const blank = " ".repeat(lineNumWidth);
32592
- let oldLineNum = 1;
32593
- let newLineNum = 1;
32594
- let lastWasChange = false;
32595
- let firstChangedLine;
32596
- for (let i = 0;i < parts.length; i++) {
32597
- const part = parts[i];
32598
- const raw = part.value.split(`
32477
+ if (lines.length <= maxLines)
32478
+ return text;
32479
+ return lines.slice(-maxLines).join(`
32599
32480
  `);
32600
- if (raw[raw.length - 1] === "")
32601
- raw.pop();
32602
- if (part.added || part.removed) {
32603
- if (firstChangedLine === undefined)
32604
- firstChangedLine = newLineNum;
32605
- for (const line of raw) {
32606
- if (part.added) {
32607
- output.push(`+${pad(newLineNum)} ${line}`);
32608
- newLineNum++;
32609
- } else {
32610
- output.push(`-${pad(oldLineNum)} ${line}`);
32611
- oldLineNum++;
32481
+ }
32482
+ function reuseText3(last) {
32483
+ return last instanceof Text3 ? last : new Text3("", 0, 0);
32484
+ }
32485
+ function reuseContainer3(last) {
32486
+ return last instanceof Container3 ? last : new Container3;
32487
+ }
32488
+ function getBashSpawnHook(pi) {
32489
+ const api2 = pi;
32490
+ if (typeof api2.getHook === "function") {
32491
+ return api2.getHook("bashSpawn");
32492
+ }
32493
+ return api2.hooks?.bashSpawn;
32494
+ }
32495
+ function registerBashTool(pi, ctx) {
32496
+ const spawnHook = getBashSpawnHook(pi);
32497
+ pi.registerTool({
32498
+ name: "bash",
32499
+ label: "bash",
32500
+ description: 'Execute shell commands through AFT\'s Rust bash handler. By default, output is compressed. Pass `compressed: false` for raw output. Pass `background: true` to spawn in the background and get a task_id for `bash_status`/`bash_kill`. Pass `pty: true` with `background: true` for interactive programs and drive them with `bash_status({ output_mode: "screen" })` plus `bash_write`.',
32501
+ promptSnippet: "Run shell commands (timeout in milliseconds; supports workdir, background tasks, compressed output, PTY mode)",
32502
+ promptGuidelines: [
32503
+ "Use bash only when a dedicated AFT tool is not a better fit.",
32504
+ "Set compressed: false when you need ANSI color codes in the output."
32505
+ ],
32506
+ parameters: BashParams,
32507
+ async execute(_toolCallId, params, _signal, onUpdate, extCtx) {
32508
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32509
+ const foregroundWaitMs = resolveBashConfig(ctx.config).foreground_wait_window_ms;
32510
+ const timeout = coerceOptionalInt(params.timeout, "timeout", 1, Number.MAX_SAFE_INTEGER);
32511
+ const ptyRows = coerceOptionalInt(params.ptyRows, "ptyRows", 1, 60);
32512
+ const ptyCols = coerceOptionalInt(params.ptyCols, "ptyCols", 1, 140);
32513
+ const effectiveBackground = params.background === true || params.pty === true;
32514
+ let spawnContext = {
32515
+ command: params.command,
32516
+ cwd: params.workdir
32517
+ };
32518
+ if (spawnHook) {
32519
+ try {
32520
+ spawnContext = await spawnHook(spawnContext);
32521
+ } catch (hookErr) {
32522
+ throw new Error(`BashSpawnHook failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
32612
32523
  }
32613
32524
  }
32614
- lastWasChange = true;
32615
- continue;
32616
- }
32617
- const nextIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
32618
- const hasLeading = lastWasChange;
32619
- const hasTrailing = nextIsChange;
32620
- if (hasLeading && hasTrailing) {
32621
- if (raw.length <= contextLines * 2) {
32622
- for (const line of raw) {
32623
- output.push(` ${pad(oldLineNum)} ${line}`);
32624
- oldLineNum++;
32625
- newLineNum++;
32626
- }
32627
- } else {
32628
- for (const line of raw.slice(0, contextLines)) {
32629
- output.push(` ${pad(oldLineNum)} ${line}`);
32630
- oldLineNum++;
32631
- newLineNum++;
32525
+ let streamed = "";
32526
+ const response = await callBridge(bridge, "bash", {
32527
+ command: spawnContext.command,
32528
+ timeout,
32529
+ workdir: spawnContext.cwd ?? params.workdir,
32530
+ env: spawnContext.env,
32531
+ description: params.description,
32532
+ background: effectiveBackground,
32533
+ notify_on_completion: effectiveBackground,
32534
+ compressed: params.compressed,
32535
+ pty: params.pty,
32536
+ pty_rows: ptyRows,
32537
+ pty_cols: ptyCols
32538
+ }, extCtx, {
32539
+ transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS,
32540
+ keepBridgeOnTimeout: true,
32541
+ onProgress: ({ text }) => {
32542
+ streamed += text;
32543
+ const displayText = truncateToVisualLines(streamed, 100);
32544
+ onUpdate?.(bashResult(displayText, { streaming: true }));
32632
32545
  }
32633
- const skipped = raw.length - contextLines * 2;
32634
- output.push(` ${blank} ...`);
32635
- oldLineNum += skipped;
32636
- newLineNum += skipped;
32637
- for (const line of raw.slice(raw.length - contextLines)) {
32638
- output.push(` ${pad(oldLineNum)} ${line}`);
32639
- oldLineNum++;
32640
- newLineNum++;
32546
+ }).catch((err) => {
32547
+ if (err instanceof Error && err.message.includes("permission_required")) {
32548
+ throw new Error("Permission ask reached Pi adapter — this is a bug. Pi has no permission system.");
32641
32549
  }
32550
+ throw err;
32551
+ });
32552
+ if (response.success === false) {
32553
+ throw new Error(response.message ?? "bash failed");
32642
32554
  }
32643
- } else if (hasLeading) {
32644
- const shown = raw.slice(0, contextLines);
32645
- for (const line of shown) {
32646
- output.push(` ${pad(oldLineNum)} ${line}`);
32647
- oldLineNum++;
32648
- newLineNum++;
32649
- }
32650
- const skipped = raw.length - shown.length;
32651
- if (skipped > 0) {
32652
- output.push(` ${blank} ...`);
32653
- oldLineNum += skipped;
32654
- newLineNum += skipped;
32655
- }
32656
- } else if (hasTrailing) {
32657
- const shownCount = Math.min(contextLines, raw.length);
32658
- const shown = raw.slice(raw.length - shownCount);
32659
- const skipped = raw.length - shown.length;
32660
- if (skipped > 0) {
32661
- output.push(` ${blank} ...`);
32662
- oldLineNum += skipped;
32663
- newLineNum += skipped;
32555
+ const taskId = response.task_id;
32556
+ if (response.status === "running" && taskId) {
32557
+ if (effectiveBackground) {
32558
+ trackBgTask(resolveSessionId(extCtx), taskId);
32559
+ return bashResult(formatBackgroundLaunch(taskId, params.pty === true), {
32560
+ task_id: taskId
32561
+ });
32562
+ }
32563
+ const waitTimeoutMs = timeout !== undefined ? Math.min(timeout, foregroundWaitMs) : foregroundWaitMs;
32564
+ const startedAt = Date.now();
32565
+ while (true) {
32566
+ const status = await callBridge(bridge, "bash_status", { task_id: taskId }, extCtx);
32567
+ if (status.success === false) {
32568
+ throw new Error(status.message ?? "bash_status failed");
32569
+ }
32570
+ if (isTerminalStatus(status.status)) {
32571
+ return bashResult(formatForegroundResult(status), {
32572
+ exit_code: status.exit_code,
32573
+ duration_ms: status.duration_ms,
32574
+ truncated: status.output_truncated,
32575
+ output_path: status.output_path,
32576
+ task_id: taskId
32577
+ });
32578
+ }
32579
+ if (Date.now() - startedAt >= waitTimeoutMs) {
32580
+ const promoted = await callBridge(bridge, "bash_promote", { task_id: taskId }, extCtx);
32581
+ if (promoted.success === false) {
32582
+ throw new Error(promoted.message ?? "bash_promote failed");
32583
+ }
32584
+ trackBgTask(resolveSessionId(extCtx), taskId);
32585
+ return bashResult(formatPromotionMessage(taskId, params.timeout, foregroundWaitMs), {
32586
+ task_id: taskId
32587
+ });
32588
+ }
32589
+ await sleep(FOREGROUND_POLL_INTERVAL_MS);
32590
+ }
32664
32591
  }
32665
- for (const line of shown) {
32666
- output.push(` ${pad(oldLineNum)} ${line}`);
32667
- oldLineNum++;
32668
- newLineNum++;
32669
- }
32670
- } else {
32671
- oldLineNum += raw.length;
32672
- newLineNum += raw.length;
32592
+ const details = {
32593
+ exit_code: response.exit_code,
32594
+ duration_ms: response.duration_ms,
32595
+ truncated: response.truncated,
32596
+ output_path: response.output_path,
32597
+ task_id: taskId
32598
+ };
32599
+ const output = response.output ?? "";
32600
+ return bashResult(output, details);
32601
+ },
32602
+ renderCall(args, theme, context) {
32603
+ return renderBashCall(args?.command, args?.description, theme, context);
32604
+ },
32605
+ renderResult(result, _options, theme, context) {
32606
+ return renderBashResult(result, theme, context);
32673
32607
  }
32674
- lastWasChange = false;
32608
+ });
32609
+ pi.registerTool(createBashStatusTool(ctx));
32610
+ pi.registerTool(createBashWatchTool(ctx));
32611
+ pi.registerTool(createBashWriteTool(ctx));
32612
+ pi.registerTool(createBashKillTool(ctx));
32613
+ }
32614
+ function formatBackgroundLaunch(taskId, isPty) {
32615
+ if (isPty) {
32616
+ return `PTY task started: ${taskId}. Use bash_status({ task_id: "${taskId}", output_mode: "screen" }) to see the visible terminal, bash_write({ task_id: "${taskId}", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits.`;
32675
32617
  }
32676
- return { diff: output.join(`
32677
- `), firstChangedLine };
32618
+ return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
32678
32619
  }
32679
-
32680
- // src/tools/hoisted.ts
32681
- var DIAGNOSTICS_PARAM_DESCRIPTION = "When true, wait up to 3 seconds for fresh LSP diagnostics on the edited file and include them in the result. Defaults to the configured `lsp.diagnostics_on_edit` value (false unless configured); per-call true/false overrides. Use aft_inspect to check diagnostics across a batch of edits or before tests/commits.";
32682
- function diagnosticsOnEditDefault(ctx) {
32683
- return ctx.config.lsp?.diagnostics_on_edit ?? false;
32620
+ function formatPromotionMessage(taskId, timeout, waitWindowMs) {
32621
+ const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
32622
+ return `Foreground bash didn't finish within ${formatSeconds(waited)} and was promoted to background: ${taskId}. A completion reminder will be delivered automatically; use bash_status({ task_id: "${taskId}" }) to inspect output or bash_kill({ task_id: "${taskId}" }) to terminate.`;
32684
32623
  }
32685
- function containsPath(parent, child) {
32686
- const rel = relative3(parent, child);
32687
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
32624
+ function formatSeconds(ms) {
32625
+ return `${Number((ms / 1000).toFixed(1))}s`;
32688
32626
  }
32689
- function expandTilde(path2) {
32690
- if (!path2 || !path2.startsWith("~"))
32691
- return path2;
32692
- if (path2 === "~")
32693
- return homedir9();
32694
- if (path2.startsWith(`~${sep}`) || path2.startsWith("~/")) {
32695
- return resolve3(homedir9(), path2.slice(2));
32627
+ function formatForegroundResult(data) {
32628
+ const output = data.output_preview ?? "";
32629
+ const outputPath = data.output_path;
32630
+ const truncated = data.output_truncated === true;
32631
+ const status = data.status;
32632
+ const exit = data.exit_code;
32633
+ let rendered = output;
32634
+ if (truncated && outputPath) {
32635
+ rendered += `
32636
+ [output truncated; full output at ${outputPath}]`;
32696
32637
  }
32697
- return path2;
32638
+ if (status === "timed_out") {
32639
+ rendered += `
32640
+ [command timed out]`;
32641
+ }
32642
+ if (typeof exit === "number" && exit !== 0) {
32643
+ rendered += `
32644
+ [exit code: ${exit}]`;
32645
+ }
32646
+ return rendered;
32698
32647
  }
32699
- function externalDirectoryPromptTimeoutMs() {
32700
- const raw = process.env.AFT_PI_EXTERNAL_PROMPT_TIMEOUT_MS;
32701
- if (raw === undefined)
32702
- return 30000;
32703
- const parsed = Number.parseInt(raw, 10);
32704
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
32648
+ function sleep(ms) {
32649
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
32705
32650
  }
32706
- async function assertExternalDirectoryPermission(extCtx, target, action = "modify", options = {}) {
32707
- if (!target)
32708
- return;
32709
- const expanded = expandTilde(target);
32710
- const absoluteTarget = isAbsolute2(expanded) ? expanded : resolve3(extCtx.cwd, expanded);
32711
- if (containsPath(extCtx.cwd, absoluteTarget))
32712
- return;
32713
- if (options.restrictToProjectRoot === false)
32714
- return;
32715
- const confirmFn = extCtx.ui?.confirm;
32716
- if (extCtx.hasUI === false || !confirmFn) {
32717
- throw new Error(`Permission denied: cannot prompt for ${action} outside the project (${absoluteTarget}).`);
32718
- }
32719
- const timeoutMs = externalDirectoryPromptTimeoutMs();
32720
- let timer;
32721
- const timeoutPromise = new Promise((resolve4) => {
32722
- timer = setTimeout(() => resolve4("timeout"), timeoutMs);
32723
- });
32724
- try {
32725
- const result = await Promise.race([
32726
- confirmFn("Allow external directory access?", `AFT wants to ${action} outside the project: ${absoluteTarget}`),
32727
- timeoutPromise
32728
- ]);
32729
- if (result === true)
32730
- return;
32731
- if (result === "timeout") {
32732
- throw new Error(`Permission denied: external directory prompt timed out after ${timeoutMs}ms.`);
32651
+ function createBashStatusTool(ctx) {
32652
+ return {
32653
+ name: "bash_status",
32654
+ label: "bash_status",
32655
+ description: "Read-only snapshot of a background bash task. Returns immediately. Never waits. Use bash_watch to block on or register for pattern matches and exit events.",
32656
+ promptSnippet: "Inspect a background bash task by task_id",
32657
+ parameters: BashStatusParams,
32658
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32659
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32660
+ const data = await bashStatusSnapshot(bridge, extCtx, params.task_id, params.output_mode);
32661
+ const details = data;
32662
+ return bashStatusResult(await formatBashStatus(extCtx, params.task_id, details, params.output_mode), details);
32733
32663
  }
32734
- throw new Error("Permission denied: external directory access was cancelled.");
32735
- } finally {
32736
- if (timer !== undefined)
32737
- clearTimeout(timer);
32738
- }
32664
+ };
32739
32665
  }
32740
- var ReadParams = Type6.Object({
32741
- path: Type6.String({
32742
- description: "Path to the file to read (absolute or relative to project root)"
32743
- }),
32744
- offset: optionalInt(1, Number.MAX_SAFE_INTEGER),
32745
- limit: optionalInt(1, Number.MAX_SAFE_INTEGER)
32746
- });
32747
- var WriteParams = Type6.Object({
32748
- filePath: Type6.String({
32749
- description: "Path to the file to write (absolute or relative to project root)"
32750
- }),
32751
- content: Type6.String({ description: "Full file contents to write" }),
32752
- diagnostics: Type6.Optional(Type6.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
32753
- });
32754
- var EditParams = Type6.Object({
32755
- filePath: Type6.String({
32756
- description: "Path to the file to edit (absolute or relative to project root)"
32757
- }),
32758
- oldString: Type6.Optional(Type6.String({ description: "Text to find (exact match, fuzzy fallback)" })),
32759
- newString: Type6.Optional(Type6.String({ description: "Replacement text (omit to delete match)" })),
32760
- replaceAll: Type6.Optional(Type6.Boolean({ description: "Replace every occurrence" })),
32761
- occurrence: optionalInt(0, Number.MAX_SAFE_INTEGER),
32762
- appendContent: Type6.Optional(Type6.String({
32763
- description: "Append text to the end of the file (creates the file if missing, parent dirs auto-created). When set, oldString/newString are ignored."
32764
- })),
32765
- diagnostics: Type6.Optional(Type6.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
32766
- });
32767
- var GrepParams = Type6.Object({
32768
- pattern: Type6.String({ description: "Regex pattern to search for" }),
32769
- path: Type6.Optional(Type6.String({
32770
- description: "Path scope (file or directory; absolute or relative to project root)"
32771
- })),
32772
- include: Type6.Optional(Type6.String({ description: "Glob filter for included files (e.g. '*.ts,*.tsx')" })),
32773
- caseSensitive: Type6.Optional(Type6.Boolean({ description: "Case-sensitive matching" })),
32774
- contextLines: optionalInt(1, Number.MAX_SAFE_INTEGER)
32775
- });
32776
- function registerHoistedTools(pi, ctx, surface) {
32777
- if (surface.hoistRead) {
32778
- pi.registerTool({
32779
- name: "read",
32780
- label: "read",
32781
- description: "Read file contents with line numbers. Backed by AFT's indexed Rust reader — faster than the built-in `read` on large repos and correctly handles images/PDFs as attachments.",
32782
- promptSnippet: "Read file contents (supports offset/limit for large files)",
32783
- promptGuidelines: ["Use read to examine files instead of cat or sed."],
32784
- parameters: ReadParams,
32785
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32786
- const bridge = bridgeFor(ctx, extCtx.cwd);
32787
- const offset = coerceOptionalInt(params.offset, "offset", 1, Number.MAX_SAFE_INTEGER);
32788
- const limit = coerceOptionalInt(params.limit, "limit", 1, Number.MAX_SAFE_INTEGER);
32789
- const req = { file: params.path };
32790
- if (offset !== undefined) {
32791
- req.start_line = offset;
32792
- if (limit !== undefined) {
32793
- req.end_line = offset + limit - 1;
32794
- }
32795
- } else if (limit !== undefined) {
32796
- req.end_line = limit;
32666
+ function createBashWatchTool(ctx) {
32667
+ return {
32668
+ name: "bash_watch",
32669
+ label: "bash_watch",
32670
+ description: "Block on a background bash task until a pattern matches, it exits, or timeout elapses; or register an async pattern notification with background:true.",
32671
+ promptSnippet: "Wait for or watch a background bash task",
32672
+ parameters: BashWatchParams,
32673
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32674
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32675
+ const waitFor = parseWaitPattern(params.pattern);
32676
+ if (params.background === true) {
32677
+ if (!waitFor) {
32678
+ throw new Error("invalid_request: Use auto-reminder; bash_watch without pattern in async mode is redundant");
32797
32679
  }
32798
- const response = await callBridge(bridge, "read", req, extCtx);
32799
- if (Array.isArray(response.entries)) {
32800
- return textResult(response.entries.join(`
32801
- `));
32680
+ const notifyParams = {
32681
+ task_id: params.task_id,
32682
+ once: params.once !== false
32683
+ };
32684
+ if (waitFor.kind === "regex")
32685
+ notifyParams.regex = waitFor.source;
32686
+ else
32687
+ notifyParams.pattern = waitFor.value;
32688
+ const sessionId = resolveSessionId(extCtx);
32689
+ markExplicitControl(sessionId, params.task_id, false);
32690
+ let registered;
32691
+ try {
32692
+ registered = await callBridge(bridge, "bash_notify", notifyParams, extCtx);
32693
+ } catch (err) {
32694
+ unmarkExplicitControl(sessionId, params.task_id);
32695
+ throw err;
32802
32696
  }
32803
- let text = response.content ?? "";
32804
- const agentSpecifiedRange = offset !== undefined || limit !== undefined;
32805
- const footer = formatReadFooter(agentSpecifiedRange, response);
32806
- if (footer)
32807
- text += footer;
32808
- return textResult(text);
32697
+ if (registered.success === false) {
32698
+ unmarkExplicitControl(sessionId, params.task_id);
32699
+ const message = String(registered.message ?? "bash_notify failed");
32700
+ throw new Error(`${String(registered.code ?? "invalid_request")}: ${message}`);
32701
+ }
32702
+ markExplicitControl(sessionId, params.task_id);
32703
+ const watchDetails = { registered: true, watchId: registered.watch_id };
32704
+ return textResult(`Watch registered: ${registered.watch_id} on task ${params.task_id}
32705
+ A notification will fire when the pattern matches or the task exits.`, watchDetails);
32809
32706
  }
32810
- });
32811
- }
32812
- if (surface.hoistWrite) {
32813
- pi.registerTool({
32814
- name: "write",
32815
- label: "write",
32816
- description: "Write a file atomically with per-file backup and optional auto-format. Parent directories are created automatically. Overwrites existing files. Uses `filePath` (not `path`). Edits return as soon as the write completes unless `lsp.diagnostics_on_edit` or a per-call `diagnostics: true` requests legacy sync-wait behavior. Call `aft_inspect` afterward to check diagnostics across a batch of edits.",
32817
- promptSnippet: "Create or overwrite files (uses filePath; auto-formats; diagnostics follow lsp.diagnostics_on_edit unless overridden)",
32818
- promptGuidelines: ["Use write only for new files or complete rewrites."],
32819
- parameters: WriteParams,
32820
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32821
- await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
32822
- restrictToProjectRoot: surface.restrictToProjectRoot
32823
- });
32824
- const bridge = bridgeFor(ctx, extCtx.cwd);
32825
- const response = await callBridge(bridge, "write", {
32826
- file: params.filePath,
32827
- content: params.content,
32828
- diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
32829
- include_diff_content: true
32830
- }, extCtx);
32831
- return buildMutationResult(params.filePath, response);
32832
- },
32833
- renderCall(args, theme, context) {
32834
- return renderMutationCall("write", args?.filePath, theme, context);
32835
- },
32836
- renderResult(result, _options, theme, context) {
32837
- return renderMutationResult(result, theme, context);
32707
+ const data = await waitForBashStatus(ctx, bridge, extCtx, params.task_id, undefined, waitFor, true, Math.min(coerceOptionalInt(params.timeout_ms, "timeout_ms", 1, MAX_BASH_STATUS_WAIT_TIMEOUT_MS) ?? DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS, MAX_BASH_STATUS_WAIT_TIMEOUT_MS));
32708
+ const text = await formatBashStatus(extCtx, params.task_id, data, undefined);
32709
+ return textResult(text, data);
32710
+ }
32711
+ };
32712
+ }
32713
+ function createBashWriteTool(ctx) {
32714
+ return {
32715
+ name: "bash_write",
32716
+ label: "bash_write",
32717
+ description: 'Write input bytes to a running PTY bash task. PTY-only; check bash_status reports mode: "pty" first. ' + 'Input is either a string (verbatim bytes) or an array mixing strings and { key: "esc" | "enter" | "up" | "ctrl-c" | ... } objects ' + 'for atomic text+key sequences such as [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ]. ' + "Named keys cover enter/return/tab/space/backspace/esc/escape, arrows, home/end/page-up/page-down/delete/insert, f1..f12, and ctrl-a..ctrl-z. " + "Maximum 1 MiB per call (post-expansion).",
32718
+ promptSnippet: "Write keystrokes/input to a PTY bash task",
32719
+ parameters: BashWriteParams,
32720
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32721
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32722
+ const data = await callBridge(bridge, "bash_write", { task_id: params.task_id, input: params.input }, extCtx);
32723
+ return textResult(JSON.stringify({ bytes_written: data.bytes_written }, null, 2), data);
32724
+ }
32725
+ };
32726
+ }
32727
+ function createBashKillTool(ctx) {
32728
+ return {
32729
+ name: "bash_kill",
32730
+ label: "bash_kill",
32731
+ description: "Terminate a running background bash task spawned with bash({ background: true }).",
32732
+ promptSnippet: "Kill a background bash task by task_id",
32733
+ parameters: BashTaskParams,
32734
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32735
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32736
+ const data = await callBridge(bridge, "bash_kill", { task_id: params.task_id }, extCtx);
32737
+ if (data.success === false) {
32738
+ throw new Error(data.message ?? "bash_kill failed");
32838
32739
  }
32839
- });
32840
- }
32841
- if (surface.hoistEdit) {
32842
- pi.registerTool({
32843
- name: "edit",
32844
- label: "edit",
32845
- description: "Find-and-replace edit with progressive fuzzy matching (handles whitespace and Unicode drift). Uses `filePath`, `oldString`, `newString`. Errors on multiple matches — use `occurrence` to pick one, or `replaceAll: true`. Edits return as soon as the write completes unless `lsp.diagnostics_on_edit` or a per-call `diagnostics: true` requests legacy sync-wait behavior. Call `aft_inspect` afterward to check diagnostics across a batch of edits.",
32846
- promptSnippet: "Targeted find-and-replace (uses filePath/oldString/newString; occurrence or replaceAll for disambiguation; fuzzy whitespace matching). Pass appendContent to append to a file (creates if missing). Diagnostics follow lsp.diagnostics_on_edit unless overridden.",
32847
- promptGuidelines: [
32848
- "Prefer edit over write when changing part of an existing file.",
32849
- "Include enough surrounding context in oldString to make the match unique, or set replaceAll/occurrence explicitly.",
32850
- "Use appendContent (instead of read+write) when adding text to the end of a file."
32851
- ],
32852
- parameters: EditParams,
32853
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32854
- await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
32855
- restrictToProjectRoot: surface.restrictToProjectRoot
32856
- });
32857
- const bridge = bridgeFor(ctx, extCtx.cwd);
32858
- if (typeof params.appendContent === "string") {
32859
- const req2 = {
32860
- op: "append",
32861
- file: params.filePath,
32862
- append_content: params.appendContent,
32863
- diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
32864
- include_diff_content: true
32865
- };
32866
- const response2 = await callBridge(bridge, "edit_match", req2, extCtx);
32867
- return buildMutationResult(params.filePath, response2);
32740
+ await disposePtyTerminal(ptyCacheKey(extCtx, params.task_id));
32741
+ const details = data;
32742
+ if (details.kill_signaled === true) {
32743
+ return bashKillResult(`Task ${params.task_id}: kill_signaled`, details);
32744
+ }
32745
+ return bashKillResult(`Task ${params.task_id}: ${details.status}`, details);
32746
+ }
32747
+ };
32748
+ }
32749
+ function bashResult(output, details) {
32750
+ return {
32751
+ content: [{ type: "text", text: output }],
32752
+ details: {
32753
+ exit_code: details.exit_code,
32754
+ duration_ms: details.duration_ms,
32755
+ truncated: details.truncated,
32756
+ output_path: details.output_path,
32757
+ task_id: details.task_id,
32758
+ bg_completions: details.bg_completions
32759
+ }
32760
+ };
32761
+ }
32762
+ function bashStatusResult(output, details) {
32763
+ return {
32764
+ content: [{ type: "text", text: output }],
32765
+ details
32766
+ };
32767
+ }
32768
+ function bashKillResult(output, details) {
32769
+ return {
32770
+ content: [{ type: "text", text: output }],
32771
+ details
32772
+ };
32773
+ }
32774
+ async function bashStatusSnapshot(bridge, extCtx, taskId, outputMode, options) {
32775
+ return await callBridge(bridge, "bash_status", { task_id: taskId, output_mode: outputMode }, extCtx, options);
32776
+ }
32777
+ async function waitForBashStatus(ctx, bridge, extCtx, taskId, outputMode, waitFor, waitForExit, effectiveWaitMs) {
32778
+ const startedAt = Date.now();
32779
+ const deadline = startedAt + effectiveWaitMs;
32780
+ let spillCursor = { output: 0, stderr: 0, combined: 0 };
32781
+ let scanText = "";
32782
+ let scanBaseOffset = 0;
32783
+ const bridgeOptions = {
32784
+ keepBridgeOnTimeout: true,
32785
+ transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS
32786
+ };
32787
+ const sessionId = resolveSessionId(extCtx);
32788
+ if (waitForExit)
32789
+ markTaskWaiting(sessionId, taskId);
32790
+ let sawTerminal = false;
32791
+ try {
32792
+ while (true) {
32793
+ const data = await bashStatusSnapshot(bridge, extCtx, taskId, outputMode, bridgeOptions);
32794
+ const terminal = isTerminalStatus(data.status);
32795
+ if (waitFor) {
32796
+ const scan = await readNewTaskOutput(extCtx, taskId, data, spillCursor);
32797
+ if (scan) {
32798
+ spillCursor = scan.nextCursor;
32799
+ if (scanText.length === 0)
32800
+ scanBaseOffset = scan.baseOffset;
32801
+ scanText += scan.text;
32802
+ const match = findWaitMatch(scanText, waitFor);
32803
+ if (match) {
32804
+ if (waitForExit && terminal) {
32805
+ sawTerminal = true;
32806
+ consumeBgCompletion(sessionId, taskId);
32807
+ await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32808
+ }
32809
+ return withWaited(data, {
32810
+ reason: "matched",
32811
+ elapsed_ms: Date.now() - startedAt,
32812
+ match: match.text,
32813
+ match_offset: scanBaseOffset + Buffer.byteLength(scanText.slice(0, match.index), "utf8")
32814
+ });
32815
+ }
32868
32816
  }
32869
- const req = {
32870
- file: params.filePath,
32871
- match: params.oldString ?? "",
32872
- replacement: params.newString ?? "",
32873
- diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
32874
- include_diff_content: true
32875
- };
32876
- if (params.replaceAll === true)
32877
- req.replace_all = true;
32878
- const occurrence = coerceOptionalInt(params.occurrence, "occurrence", 0, Number.MAX_SAFE_INTEGER);
32879
- if (occurrence !== undefined)
32880
- req.occurrence = occurrence;
32881
- const response = await callBridge(bridge, "edit_match", req, extCtx);
32882
- return buildMutationResult(params.filePath, response);
32883
- },
32884
- renderCall(args, theme, context) {
32885
- return renderMutationCall("edit", args?.filePath, theme, context);
32886
- },
32887
- renderResult(result, _options, theme, context) {
32888
- return renderMutationResult(result, theme, context);
32889
32817
  }
32890
- });
32891
- }
32892
- if (surface.hoistGrep) {
32893
- pi.registerTool({
32894
- name: "grep",
32895
- label: "grep",
32896
- description: "Search for a regex pattern across files. Uses AFT's trigram index inside the project root for fast repeated queries, and falls back to ripgrep for paths outside the project root.",
32897
- promptSnippet: "Fast regex search across files (trigram-indexed inside the project root)",
32898
- promptGuidelines: ["Prefer grep over bash-invoked find/rg for in-project searches."],
32899
- parameters: GrepParams,
32900
- async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32901
- const bridge = bridgeFor(ctx, extCtx.cwd);
32902
- const req = { pattern: params.pattern };
32903
- if (params.path) {
32904
- await assertExternalDirectoryPermission(extCtx, params.path, "search", {
32905
- restrictToProjectRoot: surface.restrictToProjectRoot
32906
- });
32907
- req.path = await resolvePathArg(extCtx.cwd, params.path);
32818
+ if (terminal) {
32819
+ if (waitForExit) {
32820
+ sawTerminal = true;
32821
+ consumeBgCompletion(sessionId, taskId);
32822
+ await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32908
32823
  }
32909
- if (params.include)
32910
- req.include = splitIncludeGlobs(params.include);
32911
- if (params.caseSensitive !== undefined)
32912
- req.case_sensitive = params.caseSensitive;
32913
- const contextLines = coerceOptionalInt(params.contextLines, "contextLines", 1, Number.MAX_SAFE_INTEGER);
32914
- if (contextLines !== undefined)
32915
- req.context_lines = contextLines;
32916
- const response = await callBridge(bridge, "grep", req, extCtx);
32917
- const text = response.text ?? "";
32918
- return textResult(text);
32824
+ return withWaited(data, { reason: "exited", elapsed_ms: Date.now() - startedAt });
32825
+ }
32826
+ if (Date.now() >= deadline) {
32827
+ return withWaited(data, { reason: "timeout", elapsed_ms: Date.now() - startedAt });
32919
32828
  }
32829
+ await sleep(Math.min(BASH_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
32830
+ }
32831
+ } finally {
32832
+ if (waitForExit && !sawTerminal)
32833
+ unmarkTaskWaiting(sessionId, taskId);
32834
+ if (waitFor) {
32835
+ await disposePtyTerminal(watchPtyCacheKey(extCtx, taskId));
32836
+ }
32837
+ }
32838
+ }
32839
+ async function readNewTaskOutput(extCtx, taskId, data, cursor) {
32840
+ const outputPath = data.output_path;
32841
+ if (data.mode === "pty") {
32842
+ if (!outputPath)
32843
+ return;
32844
+ const { rows, cols } = ptyDimensions(data);
32845
+ const state = await getOrCreatePtyTerminal(watchPtyCacheKey(extCtx, taskId), outputPath, rows, cols);
32846
+ const baseOffset = state.offset;
32847
+ const bytes = await readPtyBytes(state);
32848
+ if (bytes.length === 0)
32849
+ return;
32850
+ return {
32851
+ text: bytes.toString("utf8"),
32852
+ baseOffset,
32853
+ nextCursor: { output: state.offset, stderr: 0, combined: state.offset }
32854
+ };
32855
+ }
32856
+ const stderrPath = data.stderr_path;
32857
+ if (!outputPath && !stderrPath)
32858
+ return;
32859
+ const stdoutBytes = outputPath ? await readFileBytesFrom(outputPath, cursor.output) : Buffer.alloc(0);
32860
+ const stderrBytes = stderrPath ? await readFileBytesFrom(stderrPath, cursor.stderr) : Buffer.alloc(0);
32861
+ const bytesRead = stdoutBytes.length + stderrBytes.length;
32862
+ if (bytesRead === 0)
32863
+ return;
32864
+ return {
32865
+ text: Buffer.concat([stdoutBytes, stderrBytes]).toString("utf8"),
32866
+ baseOffset: cursor.combined,
32867
+ nextCursor: {
32868
+ output: cursor.output + stdoutBytes.length,
32869
+ stderr: cursor.stderr + stderrBytes.length,
32870
+ combined: cursor.combined + bytesRead
32871
+ }
32872
+ };
32873
+ }
32874
+ async function readFileBytesFrom(outputPath, cursor) {
32875
+ const handle = await fs3.open(outputPath, "r");
32876
+ try {
32877
+ const chunks = [];
32878
+ let offset = cursor;
32879
+ while (true) {
32880
+ const buffer2 = Buffer.allocUnsafe(64 * 1024);
32881
+ const { bytesRead } = await handle.read(buffer2, 0, buffer2.length, offset);
32882
+ if (bytesRead === 0)
32883
+ break;
32884
+ chunks.push(Buffer.from(buffer2.subarray(0, bytesRead)));
32885
+ offset += bytesRead;
32886
+ }
32887
+ return Buffer.concat(chunks);
32888
+ } finally {
32889
+ await handle.close().catch(() => {
32890
+ return;
32920
32891
  });
32921
32892
  }
32922
32893
  }
32923
- function buildMutationResult(filePath, response) {
32924
- const diffObj = response.diff;
32925
- const additions = diffObj?.additions ?? 0;
32926
- const deletions = diffObj?.deletions ?? 0;
32927
- const replacements = response.replacements;
32928
- const diagnostics = response.lsp_diagnostics;
32929
- const truncated = diffObj?.truncated === true;
32930
- const noOp = response.no_op === true;
32931
- const formatted = response.formatted;
32932
- const formatSkippedReason = response.format_skipped_reason;
32933
- const globFormatSkipReasons = response.format_skip_reasons;
32934
- let diffText;
32935
- let firstChangedLine;
32936
- if (diffObj && !truncated && typeof diffObj.before === "string" && typeof diffObj.after === "string") {
32937
- const piDiff = formatDiffForPi(diffObj.before, diffObj.after);
32938
- diffText = piDiff.diff;
32939
- firstChangedLine = piDiff.firstChangedLine;
32894
+ function parseWaitPattern(value) {
32895
+ if (typeof value === "string")
32896
+ return { kind: "substring", value };
32897
+ if (isRegexWaitObject(value))
32898
+ return { kind: "regex", value: new RegExp(value.regex), source: value.regex };
32899
+ return;
32900
+ }
32901
+ function isRegexWaitObject(value) {
32902
+ return typeof value === "object" && value !== null && "regex" in value && typeof value.regex === "string";
32903
+ }
32904
+ function findWaitMatch(text, pattern) {
32905
+ if (pattern.kind === "substring") {
32906
+ const index = text.indexOf(pattern.value);
32907
+ return index >= 0 ? { text: pattern.value, index } : undefined;
32940
32908
  }
32941
- const summaryHeader = replacements !== undefined ? `Edited ${filePath} (+${additions}/-${deletions}, ${replacements} replacement${replacements === 1 ? "" : "s"})` : `Wrote ${filePath} (+${additions}/-${deletions})`;
32942
- let text = summaryHeader;
32943
- if (noOp) {
32944
- text += `
32945
-
32946
- Note: no net file change — the match was found and applied, but the file content is byte-identical to before. Likely causes: oldString and newString are identical, or a formatter normalized the change away.`;
32909
+ pattern.value.lastIndex = 0;
32910
+ const match = pattern.value.exec(text);
32911
+ return match ? { text: match[0], index: match.index } : undefined;
32912
+ }
32913
+ function withWaited(data, waited) {
32914
+ return { ...data, waited };
32915
+ }
32916
+ function formatWaitSummary(waited, details) {
32917
+ if (waited.reason === "matched") {
32918
+ return `Waited ${waited.elapsed_ms}ms; matched ${JSON.stringify(waited.match ?? "")} at offset ${waited.match_offset ?? 0}.`;
32947
32919
  }
32948
- const skipNote = formatSkipReasonNote(formatSkippedReason);
32949
- if (skipNote)
32950
- text += `
32951
-
32952
- ${skipNote}`;
32953
- const globSkipNote = formatGlobSkipReasonsNote(globFormatSkipReasons);
32954
- if (globSkipNote)
32955
- text += `
32956
-
32957
- ${globSkipNote}`;
32958
- if (diagnostics && diagnostics.length > 0) {
32959
- text += `
32960
-
32961
- LSP diagnostics:
32962
- ${formatDiagnosticsText(diagnostics)}`;
32920
+ if (waited.reason === "timeout") {
32921
+ return `Waited ${waited.elapsed_ms}ms; timeout reached without match.`;
32963
32922
  }
32964
- return {
32965
- content: [{ type: "text", text }],
32966
- details: {
32967
- diff: diffText,
32968
- firstChangedLine,
32969
- additions,
32970
- deletions,
32971
- replacements,
32972
- diagnostics,
32973
- truncated: truncated || undefined,
32974
- formatted,
32975
- formatSkippedReason,
32976
- noOp: noOp || undefined
32977
- }
32978
- };
32923
+ const exit = typeof details.exit_code === "number" ? `, exit ${details.exit_code}` : "";
32924
+ return `Waited ${waited.elapsed_ms}ms; task exited (${details.status}${exit}).`;
32979
32925
  }
32980
- function formatGlobSkipReasonsNote(reasons) {
32981
- if (!Array.isArray(reasons))
32982
- return;
32983
- const actionable = reasons.filter((reason) => typeof reason === "string").filter((reason) => ["formatter_not_installed", "formatter_excluded_path", "timeout", "error"].includes(reason));
32984
- if (actionable.length === 0)
32985
- return;
32986
- return `Note: formatter skipped some glob edit result file(s): ${[...new Set(actionable)].sort().join(", ")}. See per-file format_skipped_reason values for details.`;
32926
+ async function formatBashStatus(extCtx, taskId, details, requestedOutputMode) {
32927
+ const exit = typeof details.exit_code === "number" ? ` (exit ${details.exit_code})` : "";
32928
+ const dur = typeof details.duration_ms === "number" ? ` ${Math.round(details.duration_ms / 1000)}s` : "";
32929
+ let text = `Task ${taskId}: ${details.status}${exit}${dur}`;
32930
+ if (details.waited)
32931
+ text += `
32932
+ ${formatWaitSummary(details.waited, details)}`;
32933
+ if (details.mode === "pty") {
32934
+ text += await formatPtyStatus(extCtx, taskId, details, requestedOutputMode);
32935
+ } else {
32936
+ if (isTerminalStatus(details.status) && details.output_preview) {
32937
+ text += `
32938
+ ${details.output_preview.slice(0, 2000)}`;
32939
+ }
32940
+ if (!isTerminalStatus(details.status)) {
32941
+ text += `
32942
+ A completion reminder will be delivered automatically; don't poll.`;
32943
+ }
32944
+ }
32945
+ return text;
32987
32946
  }
32988
- function formatSkipReasonNote(reason) {
32989
- switch (reason) {
32990
- case "formatter_not_installed":
32991
- return "Note: formatter binary not installed; file written unformatted.";
32992
- case "timeout":
32993
- return "Note: formatter timed out; file written unformatted. Raise formatter_timeout_secs or check the formatter for hangs.";
32994
- case "formatter_excluded_path":
32995
- return "Note: formatter is configured to ignore this path (e.g. biome.json files.includes, .prettierignore). File written unformatted.";
32996
- case "error":
32997
- return "Note: formatter exited with an unrecognized error; file written unformatted.";
32998
- default:
32999
- return;
32947
+ async function formatPtyStatus(extCtx, taskId, details, requestedOutputMode) {
32948
+ if (!details.output_path)
32949
+ return `
32950
+ [PTY output path unavailable]`;
32951
+ const key = ptyCacheKey(extCtx, taskId);
32952
+ const { rows, cols } = ptyDimensions(details);
32953
+ const state = await getOrCreatePtyTerminal(key, details.output_path, rows, cols);
32954
+ const raw = await readPtyBytes(state);
32955
+ const outputMode = requestedOutputMode ?? "screen";
32956
+ let suffix = "";
32957
+ if (outputMode === "raw") {
32958
+ suffix = raw.length > 0 ? `
32959
+ ${raw.toString("utf8")}` : "";
32960
+ } else if (outputMode === "both") {
32961
+ suffix = `
32962
+ ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
32963
+ } else {
32964
+ const screen = renderScreen(state, rows, cols);
32965
+ suffix = screen ? `
32966
+ ${screen}` : "";
32967
+ }
32968
+ if (!isTerminalStatus(details.status)) {
32969
+ suffix += `
32970
+ PTY task is still running. Use bash_status({ task_id: "${taskId}", output_mode: "screen" }) to inspect, bash_write({ task_id: "${taskId}", input: "..." }) to send keystrokes.`;
32971
+ } else {
32972
+ await disposePtyTerminal(key);
33000
32973
  }
32974
+ return suffix;
33001
32975
  }
33002
- function formatDiagnosticsText(diagnostics) {
33003
- try {
33004
- return diagnostics.map((d) => {
33005
- if (d && typeof d === "object") {
33006
- const obj = d;
33007
- const line = obj.line ?? obj.startLine ?? "?";
33008
- const severity = obj.severity ?? "info";
33009
- const msg = obj.message ?? JSON.stringify(obj);
33010
- return ` [${severity}] line ${line}: ${msg}`;
33011
- }
33012
- return ` ${String(d)}`;
33013
- }).join(`
32976
+ function ptyDimensions(data) {
32977
+ const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
32978
+ const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
32979
+ return { rows, cols };
32980
+ }
32981
+ function ptyCacheKey(extCtx, taskId) {
32982
+ return `${extCtx.cwd}::${resolveSessionId(extCtx) ?? "__default__"}::${taskId}`;
32983
+ }
32984
+ function watchPtyCacheKey(extCtx, taskId) {
32985
+ return `${ptyCacheKey(extCtx, taskId)}::watch`;
32986
+ }
32987
+ function isTerminalStatus(status) {
32988
+ return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
32989
+ }
32990
+ function renderBashCall(command, description, theme, context) {
32991
+ const text = reuseText3(context.lastComponent);
32992
+ const display = description ?? (command ? shortenCommand(command) : "...");
32993
+ text.setText(`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", display)}`);
32994
+ return text;
32995
+ }
32996
+ function renderBashResult(result, theme, context) {
32997
+ if (context.isError) {
32998
+ const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
32999
+ `).trim();
33000
+ const text = reuseText3(context.lastComponent);
33001
+ text.setText(`
33002
+ ${theme.fg("error", errorText || "bash failed")}`);
33003
+ return text;
33004
+ }
33005
+ const details = result.details;
33006
+ const exitCode = details?.exit_code;
33007
+ const bgCompletions = details?.bg_completions ?? [];
33008
+ const container = reuseContainer3(context.lastComponent);
33009
+ container.clear();
33010
+ container.addChild(new Spacer3(1));
33011
+ const rawOutput = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
33012
+ `).trim();
33013
+ if (rawOutput) {
33014
+ const lines = rawOutput.split(`
33014
33015
  `);
33015
- } catch {
33016
- return JSON.stringify(diagnostics, null, 2);
33016
+ const preview = lines.length > 25 ? `... (${lines.length - 25} lines omitted)
33017
+ ${lines.slice(-25).join(`
33018
+ `)}` : rawOutput;
33019
+ container.addChild(new Text3(preview, 1, 0));
33020
+ container.addChild(new Spacer3(1));
33021
+ }
33022
+ if (exitCode !== undefined) {
33023
+ const exitColor = exitCode === 0 ? "success" : "error";
33024
+ const exitText = theme.fg(exitColor, `exit ${exitCode}`);
33025
+ container.addChild(new Text3(exitText, 1, 0));
33026
+ }
33027
+ if (bgCompletions.length > 0) {
33028
+ container.addChild(new Spacer3(1));
33029
+ for (const bg of bgCompletions) {
33030
+ const cmdPreview = bg.command ? bg.command.slice(0, 60) : "unknown command";
33031
+ const suffix = (bg.command?.length ?? 0) > 60 ? "..." : "";
33032
+ const exitInfo = bg.exit_code !== undefined ? `exit ${bg.exit_code}` : bg.status;
33033
+ const statusColor = bg.status === "completed" && bg.exit_code === 0 ? "success" : "warning";
33034
+ const line = theme.fg(statusColor, `Background task ${bg.task_id} completed (${exitInfo}): ${cmdPreview}${suffix}`);
33035
+ container.addChild(new Text3(line, 1, 0));
33036
+ }
33037
+ }
33038
+ if (details?.duration_ms !== undefined) {
33039
+ container.addChild(new Spacer3(1));
33040
+ const durationText = theme.fg("muted", `${details.duration_ms}ms`);
33041
+ container.addChild(new Text3(durationText, 1, 0));
33017
33042
  }
33043
+ if (details?.truncated) {
33044
+ container.addChild(new Spacer3(1));
33045
+ const truncText = theme.fg("warning", "(output truncated)");
33046
+ container.addChild(new Text3(truncText, 1, 0));
33047
+ }
33048
+ return container;
33018
33049
  }
33019
- function reuseText3(last) {
33020
- return last instanceof Text3 ? last : new Text3("", 0, 0);
33050
+ function shortenCommand(command) {
33051
+ if (command.length <= 60)
33052
+ return command;
33053
+ return `${command.slice(0, 57)}...`;
33054
+ }
33055
+
33056
+ // src/tools/conflicts.ts
33057
+ import { Type as Type5 } from "typebox";
33058
+ var ConflictsParams = Type5.Object({
33059
+ path: Type5.Optional(Type5.String({
33060
+ description: "Optional path inside the git repository or worktree to inspect (absolute or relative to project root). Conflicts are discovered from that repository's top level. Defaults to the session project root."
33061
+ }))
33062
+ });
33063
+ function renderConflictCall(theme, context) {
33064
+ return renderToolCall("conflicts", undefined, theme, context);
33065
+ }
33066
+ function buildConflictSections(text) {
33067
+ const trimmed = text.trim();
33068
+ if (!trimmed)
33069
+ return ["No merge conflicts found."];
33070
+ const [header, ...rest] = trimmed.split(/\n\n+/);
33071
+ const match = header.match(/^(\d+)\s+files?,\s+(\d+)\s+conflicts?/i);
33072
+ const sections = [
33073
+ match ? `${match[1]} conflicted file${match[1] === "1" ? "" : "s"} · ${match[2]} region${match[2] === "1" ? "" : "s"}` : header
33074
+ ];
33075
+ if (rest.length === 0)
33076
+ return sections;
33077
+ sections.push(...rest.map((section) => section.trim()).filter(Boolean));
33078
+ return sections;
33021
33079
  }
33022
- function reuseContainer3(last) {
33023
- return last instanceof Container3 ? last : new Container3;
33080
+ function renderConflictResult(text, theme, context) {
33081
+ const sections = buildConflictSections(text).map((section, index) => index === 0 ? theme.fg("warning", section) : section);
33082
+ return renderSections(sections, context);
33024
33083
  }
33025
- function renderMutationCall(toolName, filePath, theme, context) {
33026
- const text = reuseText3(context.lastComponent);
33027
- const pathDisplay = filePath ? theme.fg("accent", shortenPath2(filePath)) : theme.fg("toolOutput", "...");
33028
- text.setText(`${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`);
33029
- return text;
33084
+ function renderConflictToolResult(result, theme, context) {
33085
+ if (context.isError)
33086
+ return renderErrorResult(result, "conflicts failed", theme, context);
33087
+ return renderConflictResult(collectTextContent(result), theme, context);
33030
33088
  }
33031
- function renderMutationResult(result, theme, context) {
33032
- if (context.isError) {
33033
- const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
33034
- `).trim();
33035
- const text = reuseText3(context.lastComponent);
33036
- text.setText(`
33037
- ${theme.fg("error", errorText || "edit failed")}`);
33038
- return text;
33039
- }
33040
- const details = result.details;
33041
- const diff = typeof details?.diff === "string" ? details.diff : undefined;
33042
- if (!diff) {
33043
- const additions = details?.additions ?? 0;
33044
- const deletions = details?.deletions ?? 0;
33045
- const text = reuseText3(context.lastComponent);
33046
- const summary = theme.fg("success", `+${additions}/-${deletions}`);
33047
- let suffix = "";
33048
- if (details?.truncated) {
33049
- suffix = ` ${theme.fg("muted", "(diff truncated)")}`;
33050
- } else if (details?.noOp) {
33051
- suffix = ` ${theme.fg("muted", "(no net change)")}`;
33089
+ function registerConflictsTool(pi, ctx) {
33090
+ pi.registerTool({
33091
+ name: "aft_conflicts",
33092
+ label: "conflicts",
33093
+ description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call.",
33094
+ parameters: ConflictsParams,
33095
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33096
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33097
+ const reqParams = {};
33098
+ const path2 = params?.path;
33099
+ if (typeof path2 === "string" && path2.trim() !== "")
33100
+ reqParams.path = path2;
33101
+ const response = await callBridge(bridge, "git_conflicts", reqParams, extCtx);
33102
+ return textResult(response.text ?? JSON.stringify(response, null, 2));
33103
+ },
33104
+ renderCall(_args, theme, context) {
33105
+ return renderConflictCall(theme, context);
33106
+ },
33107
+ renderResult(result, _options, theme, context) {
33108
+ return renderConflictToolResult(result, theme, context);
33052
33109
  }
33053
- text.setText(`
33054
- ${summary}${suffix}`);
33055
- return text;
33056
- }
33057
- const container = reuseContainer3(context.lastComponent);
33058
- container.clear();
33059
- container.addChild(new Spacer3(1));
33060
- container.addChild(new Text3(renderDiff2(diff), 1, 0));
33061
- return container;
33062
- }
33063
- function shortenPath2(path2) {
33064
- const home = homedir9();
33065
- if (path2.startsWith(home))
33066
- return `~${path2.slice(home.length)}`;
33067
- return path2;
33110
+ });
33068
33111
  }
33069
- async function resolvePathArg(cwd, path2) {
33070
- const expanded = expandTilde(path2);
33071
- const abs = isAbsolute2(expanded) ? expanded : resolve3(cwd, expanded);
33072
- try {
33073
- await stat(abs);
33074
- return abs;
33075
- } catch {
33076
- return expanded;
33112
+
33113
+ // src/tools/fs.ts
33114
+ import { Type as Type6 } from "typebox";
33115
+ var DeleteParams = Type6.Object({
33116
+ files: Type6.Array(Type6.String(), {
33117
+ description: "Paths to delete (one or more). May include directories when recursive=true.",
33118
+ minItems: 1
33119
+ }),
33120
+ recursive: Type6.Optional(Type6.Boolean({
33121
+ description: "Required to delete a directory and its contents. Defaults to false; passing a directory without this returns an error."
33122
+ }))
33123
+ });
33124
+ var MoveParams = Type6.Object({
33125
+ filePath: Type6.String({
33126
+ description: "Source file path to move (absolute or relative to project root)"
33127
+ }),
33128
+ destination: Type6.String({
33129
+ description: "Destination file path (absolute or relative to project root)"
33130
+ })
33131
+ });
33132
+ function renderFsCall(toolName, args, theme, context) {
33133
+ if (toolName === "aft_delete") {
33134
+ const files = args.files;
33135
+ const summary = files.length === 1 ? accentPath(theme, files[0]) : `${theme.fg("accent", String(files.length))} ${theme.fg("muted", "files")}`;
33136
+ return renderToolCall("delete", summary, theme, context);
33077
33137
  }
33138
+ const moveArgs = args;
33139
+ return renderToolCall("move", `${accentPath(theme, moveArgs.filePath)} ${theme.fg("muted", "→")} ${accentPath(theme, moveArgs.destination)}`, theme, context);
33078
33140
  }
33079
- function splitIncludeGlobs(include) {
33080
- const out = [];
33081
- let depth = 0;
33082
- let buf = "";
33083
- for (const ch of include) {
33084
- if (ch === "{") {
33085
- depth++;
33086
- buf += ch;
33087
- continue;
33141
+ function renderFsResult(toolName, args, result, theme, context) {
33142
+ if (context.isError) {
33143
+ return renderErrorResult(result, `${toolName} failed`, theme, context);
33144
+ }
33145
+ if (toolName === "aft_delete") {
33146
+ const files = args.files;
33147
+ const data = result?.details ?? {};
33148
+ const deletedPaths = data.deleted ?? files;
33149
+ const skipped = data.skipped_files ?? [];
33150
+ const lines = [];
33151
+ for (const entry of deletedPaths) {
33152
+ lines.push(`${theme.fg("success", "✓ deleted")} ${theme.fg("accent", shortenPath2(entry))}`);
33088
33153
  }
33089
- if (ch === "}") {
33090
- if (depth > 0)
33091
- depth--;
33092
- buf += ch;
33093
- continue;
33154
+ for (const entry of skipped) {
33155
+ lines.push(`${theme.fg("error", "✗ skipped")} ${theme.fg("accent", shortenPath2(entry.file))} ${theme.fg("muted", `(${entry.reason})`)}`);
33094
33156
  }
33095
- if (ch === "," && depth === 0) {
33096
- const trimmed = buf.trim();
33097
- if (trimmed.length > 0)
33098
- out.push(trimmed);
33099
- buf = "";
33100
- continue;
33157
+ if (lines.length === 0) {
33158
+ lines.push(theme.fg("muted", "(no files deleted)"));
33101
33159
  }
33102
- buf += ch;
33160
+ return renderSections([lines.join(`
33161
+ `)], context);
33103
33162
  }
33104
- const tail2 = buf.trim();
33105
- if (tail2.length > 0)
33106
- out.push(tail2);
33107
- return out;
33163
+ const moveArgs = args;
33164
+ return renderSections([
33165
+ `${theme.fg("success", "✓ moved")} ${theme.fg("accent", shortenPath2(moveArgs.filePath))}`,
33166
+ `${theme.fg("muted", "to")} ${theme.fg("accent", shortenPath2(moveArgs.destination))}`
33167
+ ], context);
33108
33168
  }
33109
- function formatReadFooter(agentSpecifiedRange, data) {
33110
- if (agentSpecifiedRange)
33111
- return "";
33112
- if (!data.truncated)
33113
- return "";
33114
- const startLine = data.start_line;
33115
- const endLine = data.end_line;
33116
- const totalLines = data.total_lines;
33117
- if (startLine === undefined || endLine === undefined || totalLines === undefined) {
33118
- return "";
33169
+ function registerFsTools(pi, ctx, surface) {
33170
+ if (surface.delete) {
33171
+ pi.registerTool({
33172
+ name: "aft_delete",
33173
+ label: "delete",
33174
+ description: "Delete one or more files (or directories) with backup. Each file is backed up before deletion — use `aft_safety undo` to recover any of them. " + "For directories, every file inside is individually backed up before removal. Directory deletion requires recursive: true. " + "Returns { success, complete, deleted, skipped_files }: partial success is allowed; files that fail are reported in skipped_files.",
33175
+ parameters: DeleteParams,
33176
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33177
+ const files = await Promise.all(params.files.map((file2) => resolvePathArg(extCtx.cwd, file2)));
33178
+ const checked = new Set;
33179
+ for (const file2 of files) {
33180
+ if (checked.has(file2))
33181
+ continue;
33182
+ checked.add(file2);
33183
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
33184
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33185
+ });
33186
+ }
33187
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33188
+ const response = await callBridge(bridge, "delete_file", {
33189
+ files,
33190
+ recursive: params.recursive === true
33191
+ }, extCtx);
33192
+ const deletedEntries = response.deleted ?? [];
33193
+ const skipped = response.skipped_files ?? [];
33194
+ const deleted = deletedEntries.map((entry) => entry.file);
33195
+ if (deleted.length === 0 && skipped.length > 0) {
33196
+ throw new Error(`delete failed for all ${skipped.length} file(s):
33197
+ ` + skipped.map((entry) => ` ${entry.file}: ${entry.reason}`).join(`
33198
+ `));
33199
+ }
33200
+ const summary = deleted.length === 1 && skipped.length === 0 ? `Deleted ${deleted[0]}` : `Deleted ${deleted.length}/${params.files.length} file(s)`;
33201
+ return textResult(summary, {
33202
+ success: true,
33203
+ complete: skipped.length === 0,
33204
+ deleted,
33205
+ skipped_files: skipped
33206
+ });
33207
+ },
33208
+ renderCall(args, theme, context) {
33209
+ return renderFsCall("aft_delete", args, theme, context);
33210
+ },
33211
+ renderResult(result, _options, theme, context) {
33212
+ return renderFsResult("aft_delete", context.args, result, theme, context);
33213
+ }
33214
+ });
33215
+ }
33216
+ if (surface.move) {
33217
+ pi.registerTool({
33218
+ name: "aft_move",
33219
+ label: "move",
33220
+ description: "Move or rename a file with backup. Creates parent directories for the destination automatically. This operates on files at the OS level — to relocate a code symbol between files, use `aft_refactor` with op='move' instead.",
33221
+ parameters: MoveParams,
33222
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33223
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33224
+ const destination = await resolvePathArg(extCtx.cwd, params.destination);
33225
+ const checked = new Set([filePath, destination]);
33226
+ for (const file2 of checked) {
33227
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
33228
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33229
+ });
33230
+ }
33231
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33232
+ const response = await callBridge(bridge, "move_file", {
33233
+ file: filePath,
33234
+ destination
33235
+ }, extCtx);
33236
+ return textResult(`Moved ${params.filePath} → ${params.destination}`, response);
33237
+ },
33238
+ renderCall(args, theme, context) {
33239
+ return renderFsCall("aft_move", args, theme, context);
33240
+ },
33241
+ renderResult(result, _options, theme, context) {
33242
+ return renderFsResult("aft_move", context.args, result, theme, context);
33243
+ }
33244
+ });
33119
33245
  }
33120
- return `
33121
- (Showing lines ${startLine}-${endLine} of ${totalLines}. Use offset/limit to read other sections.)`;
33122
33246
  }
33123
33247
 
33124
33248
  // src/tools/imports.ts
@@ -33128,8 +33252,20 @@ var ImportParams = Type7.Object({
33128
33252
  op: StringEnum2(["add", "remove", "organize"], { description: "Import operation" }),
33129
33253
  filePath: Type7.String({ description: "Path to the file (absolute or relative to project root)" }),
33130
33254
  module: Type7.Optional(Type7.String({ description: "Module path (required for add/remove), e.g. 'react', './utils'" })),
33131
- names: Type7.Optional(Type7.Array(Type7.String(), { description: "Named imports to add, e.g. ['useState']" })),
33132
- defaultImport: Type7.Optional(Type7.String({ description: "Default import name (e.g. 'React')" })),
33255
+ names: Type7.Optional(Type7.Array(Type7.String(), {
33256
+ description: "Named imports to add, using native named-import text with per-name `as` aliasing where supported, e.g. ['useState'], Solidity ['ERC20', 'IERC20 as IToken']"
33257
+ })),
33258
+ defaultImport: Type7.Optional(Type7.String({ description: "Default import name, ES only (e.g. 'React')" })),
33259
+ namespace: Type7.Optional(Type7.String({
33260
+ description: "Namespace binding: `import * as ns from 'mod'` (ES), `* as N from \"./X.sol\"` (Solidity)"
33261
+ })),
33262
+ alias: Type7.Optional(Type7.String({ description: 'Whole-module alias. Solidity: `import "./X.sol" as X`' })),
33263
+ modifiers: Type7.Optional(Type7.Array(Type7.String(), {
33264
+ description: "Statement-level modifiers, language-validated: Java/C# 'static', C# 'global'/'unsafe', Java/Kotlin/Scala 'wildcard', Swift '@testable'"
33265
+ })),
33266
+ importKind: Type7.Optional(Type7.String({
33267
+ description: "Symbol-kind import: PHP 'function'/'const', Swift 'struct'/'class'/'enum', Scala 'given'"
33268
+ })),
33133
33269
  removeName: Type7.Optional(Type7.String({ description: "Named import to remove; omit to remove entire import" })),
33134
33270
  typeOnly: Type7.Optional(Type7.Boolean({ description: "Type-only import (TS only)" })),
33135
33271
  validate: Type7.Optional(StringEnum2(["syntax", "full"], {
@@ -33150,16 +33286,19 @@ function buildImportSections(args, payload, theme) {
33150
33286
  ];
33151
33287
  }
33152
33288
  if (args.op === "add") {
33153
- const moduleName = asString(response.module) ?? args.module ?? "(module)";
33289
+ const moduleName2 = asString(response.module) ?? args.module ?? "(module)";
33154
33290
  const status = response.already_present === true ? theme.fg("warning", "already present") : theme.fg("success", "added");
33155
33291
  return [
33156
- `${status} ${theme.fg("accent", moduleName)}`,
33292
+ `${status} ${theme.fg("accent", moduleName2)}`,
33157
33293
  `${theme.fg("muted", "file")} ${theme.fg("accent", asString(response.file) ?? args.filePath)}`,
33158
33294
  `${theme.fg("muted", "group")} ${asString(response.group) ?? "—"}`
33159
33295
  ];
33160
33296
  }
33297
+ const moduleName = asString(response.module) ?? args.module ?? "(module)";
33298
+ const didRemove = response.removed !== false;
33299
+ const removeStatus = didRemove ? `${theme.fg("success", "removed")} ${theme.fg("accent", moduleName)}` : `${theme.fg("warning", "not present")} ${theme.fg("accent", moduleName)}`;
33161
33300
  return [
33162
- `${theme.fg("success", "removed")} ${theme.fg("accent", asString(response.module) ?? args.module ?? "(module)")}`,
33301
+ removeStatus,
33163
33302
  `${theme.fg("muted", "file")} ${theme.fg("accent", asString(response.file) ?? args.filePath)}`,
33164
33303
  args.removeName ? `${theme.fg("muted", "name")} ${args.removeName}` : `${theme.fg("muted", "scope")} entire import`
33165
33304
  ];
@@ -33182,25 +33321,37 @@ function registerImportTools(pi, ctx) {
33182
33321
  pi.registerTool({
33183
33322
  name: "aft_import",
33184
33323
  label: "import",
33185
- description: "Language-aware import management. Supports TS, JS, TSX, Python, Rust, Go. Ops: `add`, `remove`, `organize`. Use aft_safety checkpoint/undo before broad cleanup.",
33324
+ description: "Language-aware import management. Supports TS, JS, TSX, Python, Rust, Go, Solidity, Java, C#, PHP, Kotlin, Scala, Swift, Ruby, Lua, C, C++, Perl, Vue. Ops: `add`, `remove`, `organize`. Use aft_safety checkpoint/undo before broad cleanup.",
33186
33325
  parameters: ImportParams,
33187
33326
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33188
- if ((params.op === "add" || params.op === "remove") && !params.module) {
33327
+ if ((params.op === "add" || params.op === "remove") && isEmptyParam(params.module)) {
33189
33328
  throw new Error(`op='${params.op}' requires 'module'`);
33190
33329
  }
33330
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33331
+ await assertExternalDirectoryPermission(extCtx, filePath, "modify", {
33332
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33333
+ });
33191
33334
  const bridge = bridgeFor(ctx, extCtx.cwd);
33192
33335
  const commandMap = {
33193
33336
  add: "add_import",
33194
33337
  remove: "remove_import",
33195
33338
  organize: "organize_imports"
33196
33339
  };
33197
- const req = { file: params.filePath };
33340
+ const req = { file: filePath };
33198
33341
  if (params.module !== undefined)
33199
33342
  req.module = params.module;
33200
33343
  if (params.names !== undefined)
33201
33344
  req.names = params.names;
33202
33345
  if (params.defaultImport !== undefined)
33203
33346
  req.default_import = params.defaultImport;
33347
+ if (params.namespace !== undefined)
33348
+ req.namespace = params.namespace;
33349
+ if (params.alias !== undefined)
33350
+ req.alias = params.alias;
33351
+ if (params.modifiers !== undefined)
33352
+ req.modifiers = params.modifiers;
33353
+ if (params.importKind !== undefined)
33354
+ req.import_kind = params.importKind;
33204
33355
  if (params.removeName !== undefined)
33205
33356
  req.name = params.removeName;
33206
33357
  if (params.typeOnly !== undefined)
@@ -33243,6 +33394,22 @@ var lastTier2TriggerAtByBridge = new WeakMap;
33243
33394
  function normalizeStringOrArray(value) {
33244
33395
  return isEmptyParam(value) ? undefined : value;
33245
33396
  }
33397
+ async function resolveAndGateScope(extCtx, ctx, scope) {
33398
+ if (scope === undefined)
33399
+ return;
33400
+ const values = Array.isArray(scope) ? scope : [scope];
33401
+ const resolved = await Promise.all(values.filter((value) => typeof value === "string" && value.length > 0).map((value) => resolvePathArg(extCtx.cwd, value)));
33402
+ const checked = new Set;
33403
+ for (const target of resolved) {
33404
+ if (checked.has(target))
33405
+ continue;
33406
+ checked.add(target);
33407
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
33408
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33409
+ });
33410
+ }
33411
+ return Array.isArray(scope) ? resolved : resolved[0];
33412
+ }
33246
33413
  function validateOptionalTopK(value) {
33247
33414
  if (value === undefined || value === null || value === "")
33248
33415
  return;
@@ -33426,7 +33593,7 @@ function registerInspectTool(pi, ctx) {
33426
33593
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33427
33594
  const bridge = bridgeFor(ctx, extCtx.cwd);
33428
33595
  const sections = normalizeStringOrArray(params.sections);
33429
- const scope = normalizeStringOrArray(params.scope);
33596
+ const scope = await resolveAndGateScope(extCtx, ctx, normalizeStringOrArray(params.scope));
33430
33597
  const topK = validateOptionalTopK(params.topK);
33431
33598
  const response = await callBridge(bridge, "inspect", { sections, scope, topK }, extCtx);
33432
33599
  runPendingTier2Categories(bridge, tier2RefreshCategories(response), extCtx);
@@ -33468,7 +33635,7 @@ function treeLine(depth, text) {
33468
33635
  }
33469
33636
  function renderCallTreeNode(node, depth, lines) {
33470
33637
  const name = asString(node.name) ?? "(unknown)";
33471
- const file2 = shortenPath(asString(node.file) ?? "(unknown file)");
33638
+ const file2 = shortenPath2(asString(node.file) ?? "(unknown file)");
33472
33639
  const line = asNumber(node.line);
33473
33640
  lines.push(treeLine(depth, `${name} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
33474
33641
  asRecords(node.children).forEach((child) => {
@@ -33487,7 +33654,7 @@ function renderTracePath(path2, index, lines) {
33487
33654
  lines.push(`Path ${index + 1}`);
33488
33655
  asRecords(path2.hops).forEach((hop, hopIndex) => {
33489
33656
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33490
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33657
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33491
33658
  const line = asNumber(hop.line);
33492
33659
  const entry = hop.is_entry_point === true ? " [entry]" : "";
33493
33660
  lines.push(treeLine(hopIndex + 1, `${symbol2}${entry} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
@@ -33512,7 +33679,7 @@ function buildNavigateSections(args, payload, theme) {
33512
33679
  `${theme.fg("success", `${asNumber(response.total_callers) ?? 0} caller${(asNumber(response.total_callers) ?? 0) === 1 ? "" : "s"}`)} ${theme.fg("muted", `${groups.length} file group${groups.length === 1 ? "" : "s"}`)} ${warning}`.trim()
33513
33680
  ];
33514
33681
  groups.forEach((group) => {
33515
- const file2 = shortenPath(asString(group.file) ?? "(unknown file)");
33682
+ const file2 = shortenPath2(asString(group.file) ?? "(unknown file)");
33516
33683
  const lines = [theme.fg("accent", file2)];
33517
33684
  asRecords(group.callers).forEach((caller) => {
33518
33685
  lines.push(` ↳ ${asString(caller.symbol) ?? "(unknown)"} ${theme.fg("muted", `line ${asNumber(caller.line) ?? "?"}`)}`);
@@ -33533,7 +33700,7 @@ function buildNavigateSections(args, payload, theme) {
33533
33700
  const lines = [theme.fg("success", `${path2.length} hop${path2.length === 1 ? "" : "s"}`)];
33534
33701
  path2.forEach((hop, index) => {
33535
33702
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33536
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33703
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33537
33704
  const line = asNumber(hop.line);
33538
33705
  lines.push(treeLine(index + 1, `${symbol2} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
33539
33706
  });
@@ -33564,7 +33731,7 @@ function buildNavigateSections(args, payload, theme) {
33564
33731
  if (callers.length === 0)
33565
33732
  sections2.push(theme.fg("muted", "No impacted callers found."));
33566
33733
  callers.forEach((caller) => {
33567
- const file2 = shortenPath(asString(caller.caller_file) ?? "(unknown file)");
33734
+ const file2 = shortenPath2(asString(caller.caller_file) ?? "(unknown file)");
33568
33735
  const symbol2 = asString(caller.caller_symbol) ?? "(unknown)";
33569
33736
  const line = asNumber(caller.line) ?? 0;
33570
33737
  const entry = caller.is_entry_point === true ? ` ${theme.fg("warning", "[entry]")}` : "";
@@ -33587,7 +33754,7 @@ function buildNavigateSections(args, payload, theme) {
33587
33754
  if (hops.length === 0)
33588
33755
  sections.push(theme.fg("muted", "No data-flow hops found."));
33589
33756
  hops.forEach((hop, index) => {
33590
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33757
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33591
33758
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33592
33759
  const variable = asString(hop.variable) ?? "(unknown)";
33593
33760
  const line = asNumber(hop.line) ?? 0;
@@ -33617,27 +33784,44 @@ function registerNavigateTool(pi, ctx) {
33617
33784
  description: "Answer code-relationship questions from a real call graph — instead of grep + read chains. Reach for this whenever the question is about how symbols connect. All ops require both `filePath` and `symbol`. Use `callers` for call sites (before renaming/signature changes), `impact` for blast radius (what breaks if a symbol changes), `call_tree` for what a function calls, `trace_to` for how execution reaches a symbol from entry points, `trace_to_symbol` for the shortest path from one symbol to another (requires `toSymbol`; if ambiguous, the error returns candidate files — retry with `toFile`), `trace_data` to follow a value across assignments/params.",
33618
33785
  parameters: navigateParamsSchema(),
33619
33786
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33620
- if (params.op === "trace_data" && !params.expression) {
33787
+ if (isEmptyParam(params.filePath)) {
33788
+ throw new Error(`op='${params.op}' requires a \`filePath\``);
33789
+ }
33790
+ if (isEmptyParam(params.symbol)) {
33791
+ throw new Error(`op='${params.op}' requires a \`symbol\``);
33792
+ }
33793
+ if (params.op === "trace_data" && isEmptyParam(params.expression)) {
33621
33794
  throw new Error("op='trace_data' requires an `expression`");
33622
33795
  }
33623
- if (params.op === "trace_to_symbol" && !params.toSymbol) {
33796
+ if (params.op === "trace_to_symbol" && isEmptyParam(params.toSymbol)) {
33624
33797
  throw new Error("op='trace_to_symbol' requires a `toSymbol`");
33625
33798
  }
33799
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33800
+ const toFile = !isEmptyParam(params.toFile) ? await resolvePathArg(extCtx.cwd, params.toFile) : undefined;
33801
+ const checked = new Set;
33802
+ for (const target of [filePath, ...toFile !== undefined ? [toFile] : []]) {
33803
+ if (checked.has(target))
33804
+ continue;
33805
+ checked.add(target);
33806
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
33807
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33808
+ });
33809
+ }
33626
33810
  const bridge = bridgeFor(ctx, extCtx.cwd);
33627
33811
  const req = {
33628
33812
  op: params.op,
33629
- file: params.filePath,
33813
+ file: filePath,
33630
33814
  symbol: params.symbol
33631
33815
  };
33632
33816
  const depth = coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER);
33633
33817
  if (depth !== undefined)
33634
33818
  req.depth = depth;
33635
- if (params.expression !== undefined)
33819
+ if (!isEmptyParam(params.expression))
33636
33820
  req.expression = params.expression;
33637
- if (params.toSymbol !== undefined)
33821
+ if (!isEmptyParam(params.toSymbol))
33638
33822
  req.toSymbol = params.toSymbol;
33639
- if (params.toFile !== undefined)
33640
- req.toFile = params.toFile;
33823
+ if (toFile !== undefined)
33824
+ req.toFile = toFile;
33641
33825
  const response = await callBridge(bridge, params.op, req, extCtx);
33642
33826
  return textResult(JSON.stringify(response, null, 2));
33643
33827
  },
@@ -33652,7 +33836,6 @@ function registerNavigateTool(pi, ctx) {
33652
33836
 
33653
33837
  // src/tools/reading.ts
33654
33838
  import { stat as stat2 } from "node:fs/promises";
33655
- import { resolve as resolve4 } from "node:path";
33656
33839
  import { Type as Type10 } from "typebox";
33657
33840
  var OutlineParams = Type10.Object({
33658
33841
  target: Type10.Union([Type10.String(), Type10.Array(Type10.String())], {
@@ -33677,11 +33860,26 @@ var ZoomParams = Type10.Object({
33677
33860
  targets: Type10.Optional(Type10.Union([ZoomTarget, Type10.Array(ZoomTarget)], {
33678
33861
  description: "Cross-file batch: `{ filePath, symbol }` or an array of them. Mutually exclusive with filePath/url/symbols."
33679
33862
  })),
33680
- contextLines: Type10.Optional(Type10.Number({ description: "Lines of context before/after (default: 3)" }))
33863
+ contextLines: Type10.Optional(Type10.Number({ description: "Lines of context before/after (default: 3)" })),
33864
+ callgraph: Type10.Optional(Type10.Boolean({
33865
+ description: "Include call-graph annotations (calls-out / called-by within the same file). Default false; off keeps zoom output minimal."
33866
+ }))
33681
33867
  });
33682
33868
  function isUrl(s) {
33683
33869
  return s.startsWith("http://") || s.startsWith("https://");
33684
33870
  }
33871
+ async function assertReadPathPermissions(extCtx, ctx, paths) {
33872
+ const targets = Array.isArray(paths) ? paths : [paths];
33873
+ const checked = new Set;
33874
+ for (const target of targets) {
33875
+ if (!target || checked.has(target))
33876
+ continue;
33877
+ checked.add(target);
33878
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
33879
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33880
+ });
33881
+ }
33882
+ }
33685
33883
  function zoomTargetLabel(args) {
33686
33884
  return args.filePath ?? args.url ?? "(no target)";
33687
33885
  }
@@ -33698,22 +33896,24 @@ function buildOutlineSections(text, theme) {
33698
33896
  }
33699
33897
  function buildZoomSections(args, payload, theme) {
33700
33898
  const batch = asRecord2(payload);
33701
- if (Array.isArray(batch?.symbols)) {
33702
- const header = batch.complete === false ? [theme.fg("warning", "Incomplete zoom results")] : [];
33703
- const items2 = batch.symbols;
33899
+ const batchItems = Array.isArray(batch?.symbols) ? batch.symbols : Array.isArray(batch?.entries) ? batch.entries : null;
33900
+ if (batchItems) {
33901
+ const header = batch?.complete === false ? [theme.fg("warning", "Incomplete zoom results")] : [];
33704
33902
  return [
33705
33903
  ...header,
33706
- ...items2.map((item) => {
33904
+ ...batchItems.map((item) => {
33707
33905
  const record2 = asRecord2(item);
33708
33906
  if (!record2)
33709
33907
  return theme.fg("muted", "No zoom result available.");
33710
33908
  const name = asString(record2.name) ?? "(unknown symbol)";
33909
+ const itemTargetLabel = asString(record2.targetLabel) ?? zoomTargetLabel(args);
33711
33910
  if (record2.success === false) {
33712
- return theme.fg("error", `Symbol "${name}" not found: ${asString(record2.error) ?? "zoom failed"}`);
33911
+ const location = record2.targetLabel ? ` in ${shortenPath2(itemTargetLabel)}` : "";
33912
+ return theme.fg("error", `Symbol "${name}" not found${location}: ${asString(record2.error) ?? "zoom failed"}`);
33713
33913
  }
33714
33914
  const content = asString(record2.content);
33715
33915
  return [
33716
- `${theme.fg("accent", name)} ${theme.fg("muted", shortenPath(zoomTargetLabel(args)))}`,
33916
+ `${theme.fg("accent", name)} ${theme.fg("muted", shortenPath2(itemTargetLabel))}`,
33717
33917
  content
33718
33918
  ].filter(Boolean).join(`
33719
33919
  `);
@@ -33733,7 +33933,7 @@ function buildZoomSections(args, payload, theme) {
33733
33933
  const startLine = range && typeof range.start_line === "number" ? range.start_line : undefined;
33734
33934
  const endLine = range && typeof range.end_line === "number" ? range.end_line : undefined;
33735
33935
  const targetLabel = zoomTargetLabel(args);
33736
- const location = startLine !== undefined ? `${shortenPath(targetLabel)}:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : shortenPath(targetLabel);
33936
+ const location = startLine !== undefined ? `${shortenPath2(targetLabel)}:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : shortenPath2(targetLabel);
33737
33937
  const lines = [`${theme.fg("accent", name)} ${theme.fg("muted", `[${kind}] ${location}`)}`];
33738
33938
  const content = asString(record2.content);
33739
33939
  if (content) {
@@ -33810,7 +34010,9 @@ Pass a single \`target\`:
33810
34010
  if (target.length === 0) {
33811
34011
  throw new Error("'target' must be a non-empty string or array of strings");
33812
34012
  }
33813
- const response3 = await callBridge(bridge, "outline", { target, files: true }, extCtx);
34013
+ const resolvedTargets = await Promise.all(target.map((entry) => resolvePathArg(extCtx.cwd, entry)));
34014
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34015
+ const response3 = await callBridge(bridge, "outline", { target: resolvedTargets, files: true }, extCtx);
33814
34016
  if (response3.success === false) {
33815
34017
  throw new Error(response3.message || "outline failed");
33816
34018
  }
@@ -33819,13 +34021,14 @@ Pass a single \`target\`:
33819
34021
  if (typeof target !== "string" || target.length === 0) {
33820
34022
  throw new Error("'target' must be a non-empty string or array of strings");
33821
34023
  }
34024
+ const resolvedTarget2 = await resolvePathArg(extCtx.cwd, target);
33822
34025
  let isDirectory2 = false;
33823
34026
  try {
33824
- const resolved = resolve4(extCtx.cwd, target);
33825
- const st = await stat2(resolved);
34027
+ const st = await stat2(resolvedTarget2);
33826
34028
  isDirectory2 = st.isDirectory();
33827
34029
  } catch {}
33828
- const request = isDirectory2 ? { directory: resolve4(extCtx.cwd, target), files: true } : { file: target, files: true };
34030
+ await assertReadPathPermissions(extCtx, ctx, resolvedTarget2);
34031
+ const request = isDirectory2 ? { directory: resolvedTarget2, files: true } : { file: resolvedTarget2, files: true };
33829
34032
  const response2 = await callBridge(bridge, "outline", request, extCtx);
33830
34033
  if (response2.success === false) {
33831
34034
  throw new Error(response2.message || "outline failed");
@@ -33840,24 +34043,26 @@ Pass a single \`target\`:
33840
34043
  return textResult(formatOutlineText(response2));
33841
34044
  }
33842
34045
  if (isArray) {
33843
- const response2 = await callBridge(bridge, "outline", { files: target }, extCtx);
34046
+ const resolvedTargets = await Promise.all(target.map((entry) => resolvePathArg(extCtx.cwd, entry)));
34047
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34048
+ const response2 = await callBridge(bridge, "outline", { files: resolvedTargets }, extCtx);
33844
34049
  return textResult(formatOutlineText(response2));
33845
34050
  }
33846
34051
  if (typeof target !== "string" || target.length === 0) {
33847
34052
  throw new Error("'target' must be a non-empty string or array of strings");
33848
34053
  }
34054
+ const resolvedTarget = await resolvePathArg(extCtx.cwd, target);
33849
34055
  let isDirectory = false;
33850
34056
  try {
33851
- const resolved = resolve4(extCtx.cwd, target);
33852
- const st = await stat2(resolved);
34057
+ const st = await stat2(resolvedTarget);
33853
34058
  isDirectory = st.isDirectory();
33854
34059
  } catch {}
34060
+ await assertReadPathPermissions(extCtx, ctx, resolvedTarget);
33855
34061
  if (isDirectory) {
33856
- const dirPath = resolve4(extCtx.cwd, target);
33857
- const response2 = await callBridge(bridge, "outline", { directory: dirPath }, extCtx);
34062
+ const response2 = await callBridge(bridge, "outline", { directory: resolvedTarget }, extCtx);
33858
34063
  return textResult(JSON.stringify(response2, null, 2), response2);
33859
34064
  }
33860
- const response = await callBridge(bridge, "outline", { file: target }, extCtx);
34065
+ const response = await callBridge(bridge, "outline", { file: resolvedTarget }, extCtx);
33861
34066
  return textResult(formatOutlineText(response));
33862
34067
  },
33863
34068
  renderCall(args, theme, context) {
@@ -33872,7 +34077,7 @@ Pass a single \`target\`:
33872
34077
  pi.registerTool({
33873
34078
  name: "aft_zoom",
33874
34079
  label: "zoom",
33875
- description: "Inspect code symbols or documentation sections. For code, returns the full source of a symbol with call-graph annotations (calls/called-by). For Markdown and HTML, returns the section content under the given heading.\n\nUse exactly ONE mode: `{ filePath, symbols }`, `{ url, symbols }`, or `{ targets }`. `symbols` can be a string or array (one or many lookups in the same file/URL). Use `targets` for cross-file batches: `{ filePath, symbol }` or an array of them.",
34080
+ description: "Inspect code symbols or documentation sections. For code, returns the full source of a symbol. Pass `callgraph: true` to also include call-graph annotations (calls-out / called-by within the same file). For Markdown and HTML, returns the section content under the given heading.\n\nUse exactly ONE mode: `{ filePath, symbols }`, `{ url, symbols }`, or `{ targets }`. `symbols` can be a string or array (one or many lookups in the same file/URL). Use `targets` for cross-file batches: `{ filePath, symbol }` or an array of them.",
33876
34081
  parameters: ZoomParams,
33877
34082
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33878
34083
  const bridge = bridgeFor(ctx, extCtx.cwd);
@@ -33896,6 +34101,7 @@ Pass a single \`target\`:
33896
34101
  const hasUrl = !isEmptyParam(params.url);
33897
34102
  const hasTargets = hasTargetsProvided(params.targets);
33898
34103
  const hasSymbols = !isEmptyParam(params.symbols);
34104
+ const wantCallgraph = params.callgraph === true;
33899
34105
  if (hasTargets) {
33900
34106
  if (hasFilePath || hasUrl || hasSymbols) {
33901
34107
  throw new Error("'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'");
@@ -33912,10 +34118,17 @@ Pass a single \`target\`:
33912
34118
  throw new Error(`targets[${i}].symbol must be a non-empty string`);
33913
34119
  }
33914
34120
  }
33915
- const responses = await Promise.all(targets.map((t) => {
33916
- const req2 = { file: t.filePath, symbol: t.symbol };
34121
+ const resolvedTargets = await Promise.all(targets.map((t) => resolvePathArg(extCtx.cwd, t.filePath)));
34122
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34123
+ const responses = await Promise.all(targets.map((t, index) => {
34124
+ const req2 = {
34125
+ file: resolvedTargets[index],
34126
+ symbol: t.symbol
34127
+ };
33917
34128
  if (params.contextLines !== undefined)
33918
34129
  req2.context_lines = params.contextLines;
34130
+ if (wantCallgraph)
34131
+ req2.callgraph = true;
33919
34132
  return callBridge(bridge, "zoom", req2, extCtx).catch((err) => ({
33920
34133
  success: false,
33921
34134
  message: err instanceof Error ? err.message : String(err)
@@ -33935,7 +34148,9 @@ Pass a single \`target\`:
33935
34148
  if (hasFilePath && hasUrl) {
33936
34149
  throw new Error("Provide exactly ONE of 'filePath' or 'url' — not both");
33937
34150
  }
33938
- const file2 = hasUrl ? params.url : params.filePath;
34151
+ const file2 = hasUrl ? params.url : await resolvePathArg(extCtx.cwd, params.filePath);
34152
+ if (!hasUrl)
34153
+ await assertReadPathPermissions(extCtx, ctx, file2);
33939
34154
  const targetLabel = (hasUrl ? params.url : params.filePath) ?? file2;
33940
34155
  const symbolsArray = hasSymbols ? typeof params.symbols === "string" ? [params.symbols] : params.symbols : undefined;
33941
34156
  if (symbolsArray) {
@@ -33943,6 +34158,8 @@ Pass a single \`target\`:
33943
34158
  const req2 = { file: file2, symbol: sym };
33944
34159
  if (params.contextLines !== undefined)
33945
34160
  req2.context_lines = params.contextLines;
34161
+ if (wantCallgraph)
34162
+ req2.callgraph = true;
33946
34163
  return callBridge(bridge, "zoom", req2, extCtx).catch((err) => ({
33947
34164
  success: false,
33948
34165
  message: err instanceof Error ? err.message : String(err)
@@ -33961,6 +34178,8 @@ Pass a single \`target\`:
33961
34178
  const req = { file: file2 };
33962
34179
  if (params.contextLines !== undefined)
33963
34180
  req.context_lines = params.contextLines;
34181
+ if (wantCallgraph)
34182
+ req.callgraph = true;
33964
34183
  const response = await callBridge(bridge, "zoom", req, extCtx);
33965
34184
  if (response.success === false) {
33966
34185
  throw new Error(response.message || "zoom failed");
@@ -34073,21 +34292,21 @@ function buildRefactorSections(args, payload, theme) {
34073
34292
  `${theme.fg("success", "moved symbol")} ${theme.fg("toolOutput", args.symbol ?? "(symbol)")}`,
34074
34293
  `${theme.fg("muted", "files modified")} ${asNumber(response.files_modified) ?? results.length}`,
34075
34294
  `${theme.fg("muted", "consumers updated")} ${asNumber(response.consumers_updated) ?? 0}`,
34076
- results.length > 0 ? results.map((entry) => ` ↳ ${shortenPath(asString(entry.file) ?? "(unknown file)")}`).join(`
34295
+ results.length > 0 ? results.map((entry) => ` ↳ ${shortenPath2(asString(entry.file) ?? "(unknown file)")}`).join(`
34077
34296
  `) : theme.fg("muted", "No files reported.")
34078
34297
  ];
34079
34298
  }
34080
34299
  if (args.op === "extract") {
34081
34300
  return [
34082
34301
  `${theme.fg("success", "extracted")} ${theme.fg("toolOutput", asString(response.name) ?? args.name ?? "(function)")}`,
34083
- `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath(asString(response.file) ?? args.filePath))}`,
34302
+ `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath))}`,
34084
34303
  `${theme.fg("muted", "params")} ${Array.isArray(response.parameters) ? response.parameters.join(", ") || "none" : "none"}`,
34085
34304
  `${theme.fg("muted", "return type")} ${asString(response.return_type) ?? "unknown"}`
34086
34305
  ];
34087
34306
  }
34088
34307
  return [
34089
34308
  `${theme.fg("success", "inlined")} ${theme.fg("toolOutput", asString(response.symbol) ?? args.symbol ?? "(symbol)")}`,
34090
- `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath(asString(response.file) ?? args.filePath))}`,
34309
+ `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath))}`,
34091
34310
  `${theme.fg("muted", "context")} ${asString(response.call_context) ?? "unknown"}`,
34092
34311
  `${theme.fg("muted", "substitutions")} ${asNumber(response.substitutions) ?? 0}`
34093
34312
  ];
@@ -34112,7 +34331,6 @@ function registerRefactorTool(pi, ctx) {
34112
34331
  description: "Workspace-wide refactoring that updates imports and references across files. `move` relocates a top-level symbol; `extract` pulls a line range into a new function; `inline` replaces a call site. Use aft_safety checkpoint/undo before risky refactors.",
34113
34332
  parameters: RefactorParams,
34114
34333
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
34115
- const bridge = bridgeFor(ctx, extCtx.cwd);
34116
34334
  const commandMap = {
34117
34335
  move: "move_symbol",
34118
34336
  extract: "extract_function",
@@ -34127,18 +34345,40 @@ function registerRefactorTool(pi, ctx) {
34127
34345
  if (params.op === "extract" && isEmptyParam(params.name)) {
34128
34346
  throw new Error("'name' is required for 'extract' op");
34129
34347
  }
34130
- const req = { file: params.filePath };
34348
+ const startLine = coerceOptionalInt(params.startLine, "startLine", 1, Number.MAX_SAFE_INTEGER);
34349
+ const endLine = coerceOptionalInt(params.endLine, "endLine", 1, Number.MAX_SAFE_INTEGER);
34350
+ const callSiteLine = coerceOptionalInt(params.callSiteLine, "callSiteLine", 1, Number.MAX_SAFE_INTEGER);
34351
+ if (params.op === "extract") {
34352
+ if (startLine === undefined)
34353
+ throw new Error("'startLine' is required for 'extract' op");
34354
+ if (endLine === undefined)
34355
+ throw new Error("'endLine' is required for 'extract' op");
34356
+ }
34357
+ if (params.op === "inline" && callSiteLine === undefined) {
34358
+ throw new Error("'callSiteLine' is required for 'inline' op");
34359
+ }
34360
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
34361
+ const destination = !isEmptyParam(params.destination) ? await resolvePathArg(extCtx.cwd, params.destination) : undefined;
34362
+ const permissionTargets = params.op === "move" && destination !== undefined ? [filePath, destination] : [filePath];
34363
+ const checked = new Set;
34364
+ for (const target of permissionTargets) {
34365
+ if (checked.has(target))
34366
+ continue;
34367
+ checked.add(target);
34368
+ await assertExternalDirectoryPermission(extCtx, target, "modify", {
34369
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
34370
+ });
34371
+ }
34372
+ const bridge = bridgeFor(ctx, extCtx.cwd);
34373
+ const req = { file: filePath };
34131
34374
  if (!isEmptyParam(params.symbol))
34132
34375
  req.symbol = params.symbol;
34133
- if (!isEmptyParam(params.destination))
34134
- req.destination = params.destination;
34376
+ if (destination !== undefined)
34377
+ req.destination = destination;
34135
34378
  if (!isEmptyParam(params.scope))
34136
34379
  req.scope = params.scope;
34137
34380
  if (!isEmptyParam(params.name))
34138
34381
  req.name = params.name;
34139
- const startLine = coerceOptionalInt(params.startLine, "startLine", 1, Number.MAX_SAFE_INTEGER);
34140
- const endLine = coerceOptionalInt(params.endLine, "endLine", 1, Number.MAX_SAFE_INTEGER);
34141
- const callSiteLine = coerceOptionalInt(params.callSiteLine, "callSiteLine", 1, Number.MAX_SAFE_INTEGER);
34142
34382
  if (startLine !== undefined)
34143
34383
  req.start_line = startLine;
34144
34384
  if (endLine !== undefined) {
@@ -34161,6 +34401,9 @@ function registerRefactorTool(pi, ctx) {
34161
34401
  // src/tools/safety.ts
34162
34402
  import { StringEnum as StringEnum5 } from "@earendil-works/pi-ai";
34163
34403
  import { Type as Type12 } from "typebox";
34404
+ function responsePaths(response) {
34405
+ return Array.isArray(response.paths) ? response.paths.filter((path2) => typeof path2 === "string" && path2.length > 0) : [];
34406
+ }
34164
34407
  var SafetyParams = Type12.Object({
34165
34408
  op: StringEnum5(["undo", "history", "checkpoint", "restore", "list"], {
34166
34409
  description: "Safety operation"
@@ -34185,14 +34428,14 @@ function buildSafetySections(args, payload, theme) {
34185
34428
  ];
34186
34429
  }
34187
34430
  return [
34188
- `${theme.fg("success", "restored")} ${theme.fg("accent", shortenPath(asString(response.path) ?? args.filePath ?? "(file)"))}`,
34431
+ `${theme.fg("success", "restored")} ${theme.fg("accent", shortenPath2(asString(response.path) ?? args.filePath ?? "(file)"))}`,
34189
34432
  `${theme.fg("muted", "backup")} ${asString(response.backup_id) ?? "—"}`
34190
34433
  ];
34191
34434
  }
34192
34435
  if (args.op === "history") {
34193
34436
  const entries = asRecords(response.entries);
34194
34437
  const sections2 = [
34195
- theme.fg("accent", shortenPath(asString(response.file) ?? args.filePath ?? "(file)"))
34438
+ theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath ?? "(file)"))
34196
34439
  ];
34197
34440
  if (entries.length === 0) {
34198
34441
  sections2.push(theme.fg("muted", "No history entries."));
@@ -34214,7 +34457,7 @@ function buildSafetySections(args, payload, theme) {
34214
34457
  `${theme.fg("success", "checkpoint created")} ${theme.fg("accent", asString(response.name) ?? args.name ?? "(checkpoint)")}`,
34215
34458
  `${theme.fg("muted", "files")} ${asNumber(response.file_count) ?? 0}`,
34216
34459
  skipped.length > 0 ? `${theme.fg("warning", "skipped")}
34217
- ${skipped.map((entry) => ` ↳ ${shortenPath(asString(entry.file) ?? "(file)")}: ${asString(entry.error) ?? "unknown error"}`).join(`
34460
+ ${skipped.map((entry) => ` ↳ ${shortenPath2(asString(entry.file) ?? "(file)")}: ${asString(entry.error) ?? "unknown error"}`).join(`
34218
34461
  `)}` : theme.fg("muted", "No skipped files.")
34219
34462
  ];
34220
34463
  }
@@ -34264,7 +34507,43 @@ function registerSafetyTool(pi, ctx) {
34264
34507
  if ((params.op === "checkpoint" || params.op === "restore") && !params.name) {
34265
34508
  throw new Error(`op='${params.op}' requires 'name'`);
34266
34509
  }
34510
+ const filePath = params.filePath ? await resolvePathArg(extCtx.cwd, params.filePath) : undefined;
34511
+ const files = params.files ? await Promise.all(params.files.map((file2) => resolvePathArg(extCtx.cwd, file2))) : undefined;
34267
34512
  const bridge = bridgeFor(ctx, extCtx.cwd);
34513
+ const restrictToProjectRoot = ctx.config.restrict_to_project_root ?? false;
34514
+ if (params.op === "undo") {
34515
+ const previewReq = {};
34516
+ if (filePath)
34517
+ previewReq.file = filePath;
34518
+ const preview = await callBridge(bridge, "undo_preview", previewReq, extCtx);
34519
+ for (const file2 of new Set(responsePaths(preview))) {
34520
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34521
+ restrictToProjectRoot
34522
+ });
34523
+ }
34524
+ }
34525
+ if (params.op === "checkpoint") {
34526
+ const checkpointFiles = files ?? (filePath ? [filePath] : undefined);
34527
+ if (Array.isArray(checkpointFiles)) {
34528
+ const checked = new Set;
34529
+ for (const file2 of checkpointFiles) {
34530
+ if (checked.has(file2))
34531
+ continue;
34532
+ checked.add(file2);
34533
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34534
+ restrictToProjectRoot
34535
+ });
34536
+ }
34537
+ }
34538
+ }
34539
+ if (params.op === "restore" && params.name) {
34540
+ const preview = await callBridge(bridge, "checkpoint_paths", { name: params.name }, extCtx);
34541
+ for (const file2 of new Set(responsePaths(preview))) {
34542
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34543
+ restrictToProjectRoot
34544
+ });
34545
+ }
34546
+ }
34268
34547
  const commandMap = {
34269
34548
  undo: "undo",
34270
34549
  history: "edit_history",
@@ -34276,16 +34555,16 @@ function registerSafetyTool(pi, ctx) {
34276
34555
  if (params.name)
34277
34556
  req.name = params.name;
34278
34557
  if (params.op === "checkpoint") {
34279
- if (params.files) {
34280
- req.files = params.files;
34281
- } else if (params.filePath) {
34282
- req.files = [params.filePath];
34558
+ if (files) {
34559
+ req.files = files;
34560
+ } else if (filePath) {
34561
+ req.files = [filePath];
34283
34562
  }
34284
34563
  } else {
34285
- if (params.filePath)
34286
- req.file = params.filePath;
34287
- if (params.files)
34288
- req.files = params.files;
34564
+ if (filePath)
34565
+ req.file = filePath;
34566
+ if (files)
34567
+ req.files = files;
34289
34568
  }
34290
34569
  const response = await callBridge(bridge, commandMap[params.op], req, extCtx);
34291
34570
  return textResult(JSON.stringify(response, null, 2));
@@ -34362,7 +34641,7 @@ function buildSemanticSections(args, payload, theme) {
34362
34641
  }
34363
34642
  const grouped = groupByFile(results, (result) => asString(result.file));
34364
34643
  for (const [file2, fileResults] of grouped.entries()) {
34365
- const lines = [theme.fg("accent", shortenPath(file2))];
34644
+ const lines = [theme.fg("accent", shortenPath2(file2))];
34366
34645
  fileResults.forEach((result) => {
34367
34646
  if (asString(result.kind) === "GrepLine") {
34368
34647
  const line = asNumber(result.line);
@@ -34516,8 +34795,12 @@ function registerStructureTool(pi, ctx) {
34516
34795
  parameters: TransformParams,
34517
34796
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
34518
34797
  validateTransformParams(params);
34798
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
34799
+ await assertExternalDirectoryPermission(extCtx, filePath, "modify", {
34800
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
34801
+ });
34519
34802
  const bridge = bridgeFor(ctx, extCtx.cwd);
34520
- const req = { file: params.filePath };
34803
+ const req = { file: filePath };
34521
34804
  if (params.container !== undefined)
34522
34805
  req.scope = params.container;
34523
34806
  if (params.code !== undefined)
@@ -34554,32 +34837,32 @@ function registerStructureTool(pi, ctx) {
34554
34837
  function validateTransformParams(params) {
34555
34838
  const op = params.op;
34556
34839
  if (op === "add_member") {
34557
- if (typeof params.container !== "string") {
34840
+ if (isEmptyParam(params.container)) {
34558
34841
  throw new Error("'container' is required for 'add_member' op");
34559
34842
  }
34560
- if (typeof params.code !== "string") {
34843
+ if (isEmptyParam(params.code)) {
34561
34844
  throw new Error("'code' is required for 'add_member' op");
34562
34845
  }
34563
34846
  }
34564
34847
  if (op === "add_derive" || op === "wrap_try_catch" || op === "add_decorator" || op === "add_struct_tags") {
34565
- if (typeof params.target !== "string") {
34848
+ if (isEmptyParam(params.target)) {
34566
34849
  throw new Error(`'target' is required for '${op}' op`);
34567
34850
  }
34568
34851
  }
34569
- if (op === "add_derive" && !Array.isArray(params.derives)) {
34852
+ if (op === "add_derive" && isEmptyParam(params.derives)) {
34570
34853
  throw new Error("'derives' array is required for 'add_derive' op");
34571
34854
  }
34572
- if (op === "add_decorator" && typeof params.decorator !== "string") {
34855
+ if (op === "add_decorator" && isEmptyParam(params.decorator)) {
34573
34856
  throw new Error("'decorator' is required for 'add_decorator' op");
34574
34857
  }
34575
34858
  if (op === "add_struct_tags") {
34576
- if (typeof params.field !== "string") {
34859
+ if (isEmptyParam(params.field)) {
34577
34860
  throw new Error("'field' is required for 'add_struct_tags' op");
34578
34861
  }
34579
- if (typeof params.tag !== "string") {
34862
+ if (isEmptyParam(params.tag)) {
34580
34863
  throw new Error("'tag' is required for 'add_struct_tags' op");
34581
34864
  }
34582
- if (typeof params.value !== "string") {
34865
+ if (isEmptyParam(params.value)) {
34583
34866
  throw new Error("'value' is required for 'add_struct_tags' op");
34584
34867
  }
34585
34868
  }
@@ -34720,15 +35003,15 @@ var PLUGIN_VERSION = (() => {
34720
35003
  return "0.0.0";
34721
35004
  }
34722
35005
  })();
34723
- var ANNOUNCEMENT_VERSION = "0.33.0";
35006
+ var ANNOUNCEMENT_VERSION = "0.34.0";
34724
35007
  var ANNOUNCEMENT_FEATURES = [
34725
- "New `aft_inspect` one call for codebase health: diagnostics, metrics, TODOs, dead code, unused exports, and duplicates.",
34726
- "Diagnostics now flow through `aft_inspect` (run it after a batch of edits) instead of arriving automatically on every edit.",
34727
- "`aft_navigate` is renamed to `aft_callgraph`; the Rust call graph now resolves cross-file callers.",
34728
- "Edits no longer echo the whole file back to the agent much lower token cost per edit.",
34729
- "Batch of `aft_search` correctness fixes and undo-history/SSRF/Windows hardening."
35008
+ "`aft_import` now supports 12 more languages (Solidity, Java, C#, PHP, Kotlin, Scala, Swift, Ruby, Lua, Perl, C/C++, Vue) — 17 total.",
35009
+ "New YAML + Kubernetes/CRD support across outline, zoom, search, and ast_grep.",
35010
+ "`aft_conflicts` takes an optional `path` to inspect any repo or git worktree, and names the repository it checked.",
35011
+ "`.aftignore` (layered on `.gitignore`) plus ripgrep-style `grep` that searches an explicitly-named file even when ignored.",
35012
+ "`aft_zoom` call-graph annotations are now opt-in (`callgraph: true`); `semantic.max_files` and `bash.foreground_wait_window_ms` are configurable."
34730
35013
  ];
34731
- var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/F2uWxjGnU";
35014
+ var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/DSa65w8wuf";
34732
35015
  var ALL_ONLY_TOOLS = new Set([
34733
35016
  "aft_callgraph",
34734
35017
  "aft_delete",
@@ -35033,7 +35316,7 @@ ${lines}
35033
35316
  if (onnxRuntimePromise) {
35034
35317
  await Promise.race([
35035
35318
  onnxRuntimePromise,
35036
- new Promise((resolve5) => setTimeout(() => resolve5(null), 60000))
35319
+ new Promise((resolve4) => setTimeout(() => resolve4(null), 60000))
35037
35320
  ]);
35038
35321
  }
35039
35322
  const bridge = pool.getBridge(cwd);