@cortexkit/aft-pi 0.33.0 → 0.35.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
@@ -11674,11 +11674,76 @@ function error(message, meta) {
11674
11674
  console.error(`[aft-bridge] ERROR: ${message}`);
11675
11675
  }
11676
11676
  }
11677
+ // ../aft-bridge/dist/bash-hints.js
11678
+ var CONFLICT_HINT = `
11679
+
11680
+ [Hint] Use aft_conflicts to see all conflict regions across files in a single call.`;
11681
+ var GREP_HINT = `
11682
+
11683
+ [Hint] Use the grep tool instead of bash for faster indexed search.`;
11684
+ function maybeAppendConflictsHint(output) {
11685
+ if (!output.includes("Automatic merge failed; fix conflicts"))
11686
+ return output;
11687
+ if (!/^CONFLICT \(|^error: could not apply /m.test(output))
11688
+ return output;
11689
+ return output + CONFLICT_HINT;
11690
+ }
11691
+ function maybeAppendGrepHint(output, command) {
11692
+ const probe = command !== undefined ? command : output.slice(0, 300).split(`
11693
+ `)[0] ?? "";
11694
+ if (!/\b(rg|grep)\s/.test(probe))
11695
+ return output;
11696
+ return output + GREP_HINT;
11697
+ }
11677
11698
  // ../aft-bridge/dist/bridge.js
11678
11699
  import { spawn } from "node:child_process";
11679
11700
  import { homedir } from "node:os";
11680
11701
  import { join } from "node:path";
11681
11702
  import { StringDecoder } from "node:string_decoder";
11703
+
11704
+ // ../aft-bridge/dist/status-bar.js
11705
+ var STATUS_BAR_HEARTBEAT_CALLS = 15;
11706
+ function createStatusBarEmitState() {
11707
+ return { callsSinceEmit: 0 };
11708
+ }
11709
+ function parseStatusBarCounts(value) {
11710
+ if (!value || typeof value !== "object")
11711
+ return;
11712
+ const record = value;
11713
+ const num = (key) => {
11714
+ const raw = record[key];
11715
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
11716
+ };
11717
+ return {
11718
+ errors: num("errors"),
11719
+ warnings: num("warnings"),
11720
+ dead_code: num("dead_code"),
11721
+ unused_exports: num("unused_exports"),
11722
+ duplicates: num("duplicates"),
11723
+ todos: num("todos"),
11724
+ tier2_stale: record.tier2_stale === true
11725
+ };
11726
+ }
11727
+ function countsEqual(a, b) {
11728
+ return a.errors === b.errors && a.warnings === b.warnings && a.dead_code === b.dead_code && a.unused_exports === b.unused_exports && a.duplicates === b.duplicates && a.todos === b.todos && a.tier2_stale === b.tier2_stale;
11729
+ }
11730
+ function shouldEmitStatusBar(state, next) {
11731
+ const changed = state.last === undefined || !countsEqual(state.last, next);
11732
+ state.callsSinceEmit += 1;
11733
+ const heartbeat = state.callsSinceEmit >= STATUS_BAR_HEARTBEAT_CALLS;
11734
+ if (changed || heartbeat) {
11735
+ state.last = next;
11736
+ state.callsSinceEmit = 0;
11737
+ return true;
11738
+ }
11739
+ return false;
11740
+ }
11741
+ function formatStatusBar(counts) {
11742
+ const staleMark = counts.tier2_stale ? "~" : "";
11743
+ return `[AFT E${counts.errors} W${counts.warnings} | ` + `${staleMark}D${counts.dead_code} U${counts.unused_exports} C${counts.duplicates} | ` + `T${counts.todos}]`;
11744
+ }
11745
+
11746
+ // ../aft-bridge/dist/bridge.js
11682
11747
  var DEFAULT_BRIDGE_TIMEOUT_MS = 30000;
11683
11748
  var BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
11684
11749
  var SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5000;
@@ -11789,6 +11854,7 @@ class BinaryBridge {
11789
11854
  configureWarningClients = new Map;
11790
11855
  restartResetTimer = null;
11791
11856
  lastChildActivityAt = 0;
11857
+ lastStatusBar;
11792
11858
  consecutiveRequestTimeouts = 0;
11793
11859
  errorPrefix;
11794
11860
  logger;
@@ -12166,6 +12232,7 @@ class BinaryBridge {
12166
12232
  this.spawnProcess(triggeringSessionId);
12167
12233
  }
12168
12234
  spawnProcess(triggeringSessionId) {
12235
+ this.lastStatusBar = undefined;
12169
12236
  if (triggeringSessionId) {
12170
12237
  this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
12171
12238
  } else {
@@ -12367,6 +12434,7 @@ class BinaryBridge {
12367
12434
  clearTimeout(entry.timer);
12368
12435
  this.consecutiveRequestTimeouts = 0;
12369
12436
  this.scheduleRestartCountReset();
12437
+ this.captureStatusBar(response);
12370
12438
  entry.resolve(response);
12371
12439
  } else if (typeof response.type === "string") {
12372
12440
  this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
@@ -12376,6 +12444,14 @@ class BinaryBridge {
12376
12444
  }
12377
12445
  }
12378
12446
  }
12447
+ captureStatusBar(response) {
12448
+ const parsed = parseStatusBarCounts(response.status_bar);
12449
+ if (parsed)
12450
+ this.lastStatusBar = parsed;
12451
+ }
12452
+ getStatusBar() {
12453
+ return this.lastStatusBar;
12454
+ }
12379
12455
  handleTimeout(triggeringSessionId) {
12380
12456
  this.consecutiveRequestTimeouts = 0;
12381
12457
  this.rejectAllPending(new Error(`${this.errorPrefix} bridge killed during sibling timeout — request aborted`));
@@ -13152,26 +13228,28 @@ function getManualInstallHint() {
13152
13228
  }
13153
13229
  async function ensureOnnxRuntime(storageDir) {
13154
13230
  const info = getPlatformInfo();
13155
- const ortDir = join6(storageDir, "onnxruntime", ORT_VERSION);
13156
- const libPath = join6(ortDir, info?.libName ?? "libonnxruntime.dylib");
13231
+ const ortVersionDir = join6(storageDir, "onnxruntime", ORT_VERSION);
13232
+ const libName = info?.libName ?? "libonnxruntime.dylib";
13233
+ const resolvedOrtDir = resolveCachedOnnxRuntimeDir(ortVersionDir, libName);
13234
+ const libPath = join6(resolvedOrtDir, libName);
13157
13235
  if (existsSync5(libPath)) {
13158
- const meta = readOnnxInstalledMeta(ortDir);
13236
+ const meta = readOnnxInstalledMeta(ortVersionDir);
13159
13237
  if (meta?.sha256) {
13160
13238
  try {
13161
13239
  const currentHash = sha256File(libPath);
13162
13240
  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.`);
13241
+ 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
13242
  } else {
13165
- log(`ONNX Runtime found at ${ortDir} (TOFU verified)`);
13166
- return ortDir;
13243
+ log(`ONNX Runtime found at ${resolvedOrtDir} (TOFU verified)`);
13244
+ return resolvedOrtDir;
13167
13245
  }
13168
13246
  } catch (err) {
13169
- warn(`Could not verify ONNX Runtime hash at ${ortDir}: ${err}`);
13170
- return ortDir;
13247
+ warn(`Could not verify ONNX Runtime hash at ${resolvedOrtDir}: ${err}`);
13248
+ return resolvedOrtDir;
13171
13249
  }
13172
13250
  } else {
13173
- log(`ONNX Runtime found at ${ortDir} (no recorded hash, accepting)`);
13174
- return ortDir;
13251
+ log(`ONNX Runtime found at ${resolvedOrtDir} (no recorded hash, accepting)`);
13252
+ return resolvedOrtDir;
13175
13253
  }
13176
13254
  }
13177
13255
  const systemPath = findSystemOnnxRuntime(info?.libName);
@@ -13192,8 +13270,8 @@ async function ensureOnnxRuntime(storageDir) {
13192
13270
  return null;
13193
13271
  }
13194
13272
  try {
13195
- cleanupIncompleteTargetIfUnowned(ortDir);
13196
- return await downloadOnnxRuntime(info, ortDir);
13273
+ cleanupIncompleteTargetIfUnowned(ortVersionDir);
13274
+ return await downloadOnnxRuntime(info, ortVersionDir);
13197
13275
  } finally {
13198
13276
  releaseLock(lockPath);
13199
13277
  }
@@ -13211,11 +13289,16 @@ function cleanupAbandonedStagingDirs(onnxBaseDir) {
13211
13289
  let abandoned = false;
13212
13290
  if (Number.isFinite(pid) && pid > 0) {
13213
13291
  if (process.platform === "win32") {
13214
- try {
13215
- const ageMs = Date.now() - statSync2(stagingDir).mtimeMs;
13216
- abandoned = ageMs > STALE_LOCK_MS;
13217
- } catch {
13292
+ const ownerAlive = isProcessAlive(pid);
13293
+ if (!ownerAlive) {
13218
13294
  abandoned = true;
13295
+ } else {
13296
+ try {
13297
+ const ageMs = Date.now() - statSync2(stagingDir).mtimeMs;
13298
+ abandoned = ageMs > STALE_LOCK_MS;
13299
+ } catch {
13300
+ abandoned = true;
13301
+ }
13219
13302
  }
13220
13303
  } else {
13221
13304
  abandoned = !isProcessAlive(pid);
@@ -13249,17 +13332,32 @@ var REQUIRED_ORT_MIN_MINOR = 20;
13249
13332
  var INVALID_ORT_VERSION = "<invalid>";
13250
13333
  function parseOnnxVersionFromPath(value) {
13251
13334
  const name = basename(value);
13252
- const semverish = name.match(/\.(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)(?:\.dylib)?$/);
13335
+ const semverish = name.match(/(?:^|[._-])(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)(?:\.(?:dylib|dll))?$/);
13253
13336
  if (semverish)
13254
13337
  return semverish[1].split(/[-+]/, 1)[0];
13255
13338
  return /\d+\.\d+\.\d+/.test(name) ? INVALID_ORT_VERSION : null;
13256
13339
  }
13340
+ function parseOnnxVersionFromDirectoryPath(value) {
13341
+ const parts = value.split(/[\\/]+/).filter(Boolean).reverse();
13342
+ for (const part of parts) {
13343
+ const version = parseOnnxVersionFromPath(part);
13344
+ if (version)
13345
+ return version;
13346
+ }
13347
+ return null;
13348
+ }
13349
+ function isPathInsideRoot(root, candidate) {
13350
+ const rel = relative(root, candidate);
13351
+ return rel === "" || !rel.startsWith("../") && !rel.startsWith("..\\") && rel !== ".." && !isAbsolute(rel) && !win32.isAbsolute(rel);
13352
+ }
13257
13353
  function detectOnnxVersion(libDir, libName) {
13258
13354
  try {
13259
13355
  const entries = readdirSync(libDir);
13260
13356
  const barePrefix = libName.replace(/\.(so|dylib|dll)$/, "");
13357
+ const expectedPrefix = process.platform === "win32" ? barePrefix.toLowerCase() : barePrefix;
13261
13358
  for (const entry of entries) {
13262
- if (!entry.startsWith(barePrefix))
13359
+ const comparable = process.platform === "win32" ? entry.toLowerCase() : entry;
13360
+ if (!comparable.startsWith(expectedPrefix))
13263
13361
  continue;
13264
13362
  const version = parseOnnxVersionFromPath(entry);
13265
13363
  if (version)
@@ -13269,7 +13367,7 @@ function detectOnnxVersion(libDir, libName) {
13269
13367
  if (existsSync5(base)) {
13270
13368
  try {
13271
13369
  const real = realpathSync(base);
13272
- const version = parseOnnxVersionFromPath(real);
13370
+ const version = parseOnnxVersionFromPath(real) ?? parseOnnxVersionFromDirectoryPath(real);
13273
13371
  if (version)
13274
13372
  return version;
13275
13373
  } catch {}
@@ -13280,6 +13378,7 @@ function detectOnnxVersion(libDir, libName) {
13280
13378
  return version;
13281
13379
  } catch {}
13282
13380
  }
13381
+ return parseOnnxVersionFromDirectoryPath(libDir);
13283
13382
  } catch {}
13284
13383
  return null;
13285
13384
  }
@@ -13315,6 +13414,14 @@ function directoryContainsLibrary(dir, libName) {
13315
13414
  return false;
13316
13415
  }
13317
13416
  }
13417
+ function resolveCachedOnnxRuntimeDir(ortVersionDir, libName) {
13418
+ if (existsSync5(join6(ortVersionDir, libName)))
13419
+ return ortVersionDir;
13420
+ const libSubdir = join6(ortVersionDir, "lib");
13421
+ if (existsSync5(join6(libSubdir, libName)))
13422
+ return libSubdir;
13423
+ return ortVersionDir;
13424
+ }
13318
13425
  function findSystemOnnxRuntime(libName) {
13319
13426
  if (!libName)
13320
13427
  return null;
@@ -13360,6 +13467,7 @@ function findSystemOnnxRuntime(libName) {
13360
13467
  seen.add(key);
13361
13468
  return true;
13362
13469
  });
13470
+ const unknownVersionPaths = [];
13363
13471
  for (const dir of uniquePaths) {
13364
13472
  const libPath = join6(dir, libName);
13365
13473
  if (process.platform === "win32") {
@@ -13368,23 +13476,18 @@ function findSystemOnnxRuntime(libName) {
13368
13476
  } else if (!existsSync5(libPath)) {
13369
13477
  continue;
13370
13478
  }
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
- }
13479
+ const version = detectOnnxVersion(dir, libName);
13480
+ if (!version) {
13481
+ unknownVersionPaths.push(dir);
13482
+ continue;
13483
+ }
13484
+ if (!isOnnxVersionCompatible(version)) {
13485
+ 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.`);
13486
+ continue;
13384
13487
  }
13385
13488
  return dir;
13386
13489
  }
13387
- return null;
13490
+ return unknownVersionPaths[0] ?? null;
13388
13491
  }
13389
13492
  async function downloadFileWithCap(url, destPath) {
13390
13493
  const controller = new AbortController;
@@ -13438,13 +13541,13 @@ function validateExtractedTree(stagingRoot) {
13438
13541
  const linkTarget = readlinkSync(fullPath);
13439
13542
  const resolvedTarget = resolve(dirname3(fullPath), linkTarget);
13440
13543
  const rel2 = relative(realRoot, resolvedTarget);
13441
- if (rel2.startsWith("..") || process.platform !== "win32" && rel2.startsWith("/")) {
13544
+ if (rel2.startsWith("..") || isAbsolute(rel2) || win32.isAbsolute(rel2)) {
13442
13545
  throw new Error(`extracted symlink ${fullPath} points outside staging root: ${linkTarget}`);
13443
13546
  }
13444
13547
  continue;
13445
13548
  }
13446
13549
  const rel = relative(realRoot, fullPath);
13447
- if (rel.startsWith("..") || process.platform !== "win32" && rel.startsWith("/")) {
13550
+ if (rel.startsWith("..") || isAbsolute(rel) || win32.isAbsolute(rel)) {
13448
13551
  throw new Error(`extracted entry ${fullPath} escapes staging root`);
13449
13552
  }
13450
13553
  if (lst.isDirectory()) {
@@ -13547,11 +13650,23 @@ function copyOnnxLibraries(info, extractedDir, targetDir, realFiles, symlinks, c
13547
13650
  log(`ORT extract: failed to copy optional ${libFile}: ${copyErr}`);
13548
13651
  }
13549
13652
  }
13653
+ const targetRoot = realpathSync(targetDir);
13550
13654
  for (const link of symlinks) {
13551
13655
  const dst = join6(targetDir, link.name);
13552
13656
  try {
13553
13657
  unlinkSync3(dst);
13554
13658
  } catch {}
13659
+ const dstForContainment = join6(targetRoot, link.name);
13660
+ const resolvedTarget = resolve(dirname3(dstForContainment), link.target);
13661
+ if (!isPathInsideRoot(targetRoot, resolvedTarget)) {
13662
+ const message = `ONNX Runtime symlink ${link.name} points outside install dir: ${link.target}`;
13663
+ if (requiredLibs.has(link.name)) {
13664
+ rmSync2(targetDir, { recursive: true, force: true });
13665
+ throw new Error(message);
13666
+ }
13667
+ log(`ORT extract: skipping optional symlink ${link.name}: ${message}`);
13668
+ continue;
13669
+ }
13555
13670
  try {
13556
13671
  symlinkSync(link.target, dst);
13557
13672
  } catch (symlinkErr) {
@@ -13654,9 +13769,8 @@ ${new Date().toISOString()}
13654
13769
  }
13655
13770
  const age = Date.now() - lockMtimeMs;
13656
13771
  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) {
13772
+ const ownerAlive = owningPid !== null && isProcessAlive(owningPid);
13773
+ if (ownerAlive && ageWithinFresh) {
13660
13774
  return false;
13661
13775
  }
13662
13776
  log(`[onnx] reclaiming install lock (owner_pid=${owningPid ?? "unknown"}, alive=${ownerAlive}, age_ms=${age})`);
@@ -13697,7 +13811,31 @@ function releaseLock(lockPath) {
13697
13811
  warn(`[onnx] unexpected error releasing lock ${lockPath}: ${err}`);
13698
13812
  }
13699
13813
  }
13814
+ function tasklistPidFromCsvLine(line) {
13815
+ const quoted = line.match(/"([^"]*)"/g);
13816
+ if (quoted && quoted.length >= 2)
13817
+ return quoted[1].slice(1, -1);
13818
+ const cells = line.split(",").map((cell) => cell.trim().replace(/^"|"$/g, ""));
13819
+ return cells[1] ?? null;
13820
+ }
13821
+ function isWindowsProcessAlive(pid) {
13822
+ if (!Number.isInteger(pid) || pid <= 0)
13823
+ return false;
13824
+ try {
13825
+ const output = execFileSync("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
13826
+ encoding: "utf8",
13827
+ timeout: 2000,
13828
+ windowsHide: true
13829
+ });
13830
+ const expected = String(pid);
13831
+ return output.split(/\r?\n/).some((line) => tasklistPidFromCsvLine(line) === expected);
13832
+ } catch {
13833
+ return false;
13834
+ }
13835
+ }
13700
13836
  function isProcessAlive(pid) {
13837
+ if (process.platform === "win32")
13838
+ return isWindowsProcessAlive(pid);
13701
13839
  try {
13702
13840
  process.kill(pid, 0);
13703
13841
  return true;
@@ -13762,7 +13900,8 @@ class BridgePool {
13762
13900
  onBashLongRunning: options.onBashLongRunning,
13763
13901
  onBashPatternMatch: options.onBashPatternMatch,
13764
13902
  errorPrefix: options.errorPrefix,
13765
- logger: options.logger
13903
+ logger: options.logger,
13904
+ childEnv: options.childEnv
13766
13905
  };
13767
13906
  this.configOverrides = configOverrides;
13768
13907
  if (Number.isFinite(this.idleTimeoutMs)) {
@@ -13895,6 +14034,9 @@ class BridgePool {
13895
14034
  _testGetConfigOverrides() {
13896
14035
  return { ...this.configOverrides };
13897
14036
  }
14037
+ _testGetBridgeOptions() {
14038
+ return { ...this.bridgeOptions };
14039
+ }
13898
14040
  }
13899
14041
  function normalizeKey(projectRoot) {
13900
14042
  const stripped = projectRoot.replace(/[/\\]+$/, "");
@@ -14632,7 +14774,7 @@ import {
14632
14774
  // package.json
14633
14775
  var package_default = {
14634
14776
  name: "@cortexkit/aft-pi",
14635
- version: "0.33.0",
14777
+ version: "0.35.0",
14636
14778
  type: "module",
14637
14779
  description: "Pi coding agent extension for Agent File Tools (AFT) — tree-sitter and LSP-powered code analysis",
14638
14780
  main: "dist/index.js",
@@ -14651,10 +14793,11 @@ var package_default = {
14651
14793
  typecheck: "tsc --noEmit",
14652
14794
  test: "bun test src/__tests__/",
14653
14795
  lint: "biome check src",
14654
- prepublishOnly: "bun run build"
14796
+ prepublishOnly: "bun run build",
14797
+ "test:unit": "bun test src/__tests__ --path-ignore-patterns 'src/__tests__/e2e/**'"
14655
14798
  },
14656
14799
  dependencies: {
14657
- "@cortexkit/aft-bridge": "0.33.0",
14800
+ "@cortexkit/aft-bridge": "0.35.0",
14658
14801
  "@xterm/headless": "^5.5.0",
14659
14802
  "comment-json": "^5.0.0",
14660
14803
  diff: "^8.0.4",
@@ -14662,12 +14805,12 @@ var package_default = {
14662
14805
  zod: "^4.1.8"
14663
14806
  },
14664
14807
  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"
14808
+ "@cortexkit/aft-darwin-arm64": "0.35.0",
14809
+ "@cortexkit/aft-darwin-x64": "0.35.0",
14810
+ "@cortexkit/aft-linux-arm64": "0.35.0",
14811
+ "@cortexkit/aft-linux-x64": "0.35.0",
14812
+ "@cortexkit/aft-win32-arm64": "0.35.0",
14813
+ "@cortexkit/aft-win32-x64": "0.35.0"
14671
14814
  },
14672
14815
  devDependencies: {
14673
14816
  "@earendil-works/pi-coding-agent": "*",
@@ -28851,6 +28994,8 @@ function date4(params) {
28851
28994
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
28852
28995
  config(en_default());
28853
28996
  // src/config.ts
28997
+ var FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 8000;
28998
+ var FOREGROUND_WAIT_WINDOW_MIN_MS = 5000;
28854
28999
  function resolveBashConfig(config2) {
28855
29000
  const top = config2.bash;
28856
29001
  const legacy = config2.experimental?.bash;
@@ -28858,13 +29003,16 @@ function resolveBashConfig(config2) {
28858
29003
  const surfaceDefaultEnabled = surface !== "minimal";
28859
29004
  const reminderEnabled = (typeof top === "object" && top !== null ? top.long_running_reminder_enabled : undefined) ?? legacy?.long_running_reminder_enabled;
28860
29005
  const reminderInterval = (typeof top === "object" && top !== null ? top.long_running_reminder_interval_ms : undefined) ?? legacy?.long_running_reminder_interval_ms;
29006
+ const rawForegroundWait = typeof top === "object" && top !== null ? top.foreground_wait_window_ms : undefined;
29007
+ const foregroundWaitWindowMs = Math.max(FOREGROUND_WAIT_WINDOW_MIN_MS, rawForegroundWait ?? FOREGROUND_WAIT_WINDOW_DEFAULT_MS);
28861
29008
  const base = {
28862
29009
  enabled: false,
28863
29010
  rewrite: false,
28864
29011
  compress: false,
28865
29012
  background: false,
28866
29013
  long_running_reminder_enabled: reminderEnabled,
28867
- long_running_reminder_interval_ms: reminderInterval
29014
+ long_running_reminder_interval_ms: reminderInterval,
29015
+ foreground_wait_window_ms: foregroundWaitWindowMs
28868
29016
  };
28869
29017
  if (top === false)
28870
29018
  return base;
@@ -28918,20 +29066,22 @@ var CheckerEnum = exports_external.enum([
28918
29066
  "staticcheck",
28919
29067
  "none"
28920
29068
  ]);
29069
+ var ConfigureWarningsDeliveryEnum = exports_external.enum(["toast", "log", "chat"]);
28921
29070
  var SemanticConfigSchema = exports_external.object({
28922
29071
  backend: exports_external.enum(["fastembed", "openai_compatible", "ollama"]).optional(),
28923
29072
  model: exports_external.string().trim().min(1).optional(),
28924
29073
  base_url: exports_external.string().trim().min(1).optional(),
28925
29074
  api_key_env: exports_external.string().trim().min(1).optional(),
28926
29075
  timeout_ms: exports_external.number().int().positive().optional(),
28927
- max_batch_size: exports_external.number().int().positive().optional()
29076
+ max_batch_size: exports_external.number().int().positive().optional(),
29077
+ max_files: exports_external.number().int().positive().optional()
28928
29078
  });
28929
29079
  var LspExtensionSchema = exports_external.string().trim().min(1).refine((value) => value.replace(/^\.+/, "").length > 0, {
28930
29080
  message: "Extension must include characters other than leading dots"
28931
29081
  });
28932
29082
  var LspServerEntrySchema = exports_external.object({
28933
- extensions: exports_external.array(LspExtensionSchema).min(1),
28934
- binary: exports_external.string().trim().min(1),
29083
+ extensions: exports_external.array(LspExtensionSchema).min(1).optional(),
29084
+ binary: exports_external.string().trim().min(1).optional(),
28935
29085
  args: exports_external.array(exports_external.string()).optional().default([]),
28936
29086
  root_markers: exports_external.array(exports_external.string().trim().min(1)).optional().default([".git"]),
28937
29087
  disabled: exports_external.boolean().optional().default(false),
@@ -28965,7 +29115,8 @@ var BashFeaturesSchema = exports_external.object({
28965
29115
  compress: exports_external.boolean().optional(),
28966
29116
  background: exports_external.boolean().optional(),
28967
29117
  long_running_reminder_enabled: exports_external.boolean().optional(),
28968
- long_running_reminder_interval_ms: exports_external.number().int().positive().optional()
29118
+ long_running_reminder_interval_ms: exports_external.number().int().positive().optional(),
29119
+ foreground_wait_window_ms: exports_external.number().int().positive().optional()
28969
29120
  });
28970
29121
  var BashConfigSchema = exports_external.union([exports_external.boolean(), BashFeaturesSchema]);
28971
29122
  var InspectConfigSchema = exports_external.object({
@@ -28993,6 +29144,7 @@ var AftConfigSchema = exports_external.object({
28993
29144
  validate_on_edit: exports_external.enum(["syntax", "full"]).optional(),
28994
29145
  formatter: exports_external.record(exports_external.string(), FormatterEnum).optional(),
28995
29146
  checker: exports_external.record(exports_external.string(), CheckerEnum).optional(),
29147
+ configure_warnings_delivery: ConfigureWarningsDeliveryEnum.optional(),
28996
29148
  tool_surface: exports_external.enum(["minimal", "recommended", "all"]).optional(),
28997
29149
  disabled_tools: exports_external.array(exports_external.string()).optional(),
28998
29150
  restrict_to_project_root: exports_external.boolean().optional(),
@@ -29031,12 +29183,16 @@ function resolveLspConfigForConfigure(config2) {
29031
29183
  const servers = Object.entries(config2.lsp?.servers ?? {}).map(([id, server]) => {
29032
29184
  const entry = {
29033
29185
  id,
29034
- extensions: server.extensions.map(normalizeLspExtension),
29035
- binary: server.binary,
29036
29186
  args: server.args,
29037
29187
  root_markers: server.root_markers,
29038
29188
  disabled: server.disabled
29039
29189
  };
29190
+ if (server.extensions && server.extensions.length > 0) {
29191
+ entry.extensions = server.extensions.map(normalizeLspExtension);
29192
+ }
29193
+ if (server.binary) {
29194
+ entry.binary = server.binary;
29195
+ }
29040
29196
  if (server.env && Object.keys(server.env).length > 0) {
29041
29197
  entry.env = server.env;
29042
29198
  }
@@ -29284,6 +29440,8 @@ function mergeSemanticConfig(base, override) {
29284
29440
  projectSafe.timeout_ms = override.timeout_ms;
29285
29441
  if (override?.max_batch_size !== undefined)
29286
29442
  projectSafe.max_batch_size = override.max_batch_size;
29443
+ if (override?.max_files !== undefined)
29444
+ projectSafe.max_files = override.max_files;
29287
29445
  const semantic = { ...base, ...projectSafe };
29288
29446
  if (Object.values(semantic).every((v) => v === undefined))
29289
29447
  return;
@@ -29381,6 +29539,7 @@ var PROJECT_SAFE_TOP_LEVEL_FIELDS = new Set([
29381
29539
  "tool_surface",
29382
29540
  "format_on_edit",
29383
29541
  "validate_on_edit",
29542
+ "configure_warnings_delivery",
29384
29543
  "search_index",
29385
29544
  "semantic_search",
29386
29545
  "inspect",
@@ -31230,6 +31389,21 @@ function sendFeatureAnnouncement(version2, features, footer, storageDir) {
31230
31389
  markAnnouncementSeen(storageDir, "pi", version2);
31231
31390
  }
31232
31391
 
31392
+ // src/status-bar-inject.ts
31393
+ var DEFAULT_SESSION = "__default__";
31394
+ var emitStateBySession = new Map;
31395
+ function statusBarBlockForSession(sessionID, counts) {
31396
+ if (!counts)
31397
+ return;
31398
+ const key = sessionID ?? DEFAULT_SESSION;
31399
+ let state = emitStateBySession.get(key);
31400
+ if (!state) {
31401
+ state = createStatusBarEmitState();
31402
+ emitStateBySession.set(key, state);
31403
+ }
31404
+ return shouldEmitStatusBar(state, counts) ? formatStatusBar(counts) : undefined;
31405
+ }
31406
+
31233
31407
  // src/shared/pty-cache.ts
31234
31408
  var import_headless = __toESM(require_xterm_headless(), 1);
31235
31409
  import * as fs2 from "node:fs/promises";
@@ -31368,1757 +31542,1811 @@ function registerShutdownCleanup(fn) {
31368
31542
 
31369
31543
  // src/tools/ast.ts
31370
31544
  import { StringEnum } from "@earendil-works/pi-ai";
31371
- import { Type as Type2 } from "typebox";
31545
+ import { Type as Type3 } from "typebox";
31372
31546
 
31373
- // src/tools/render-helpers.ts
31547
+ // src/tools/hoisted.ts
31548
+ import { stat } from "node:fs/promises";
31374
31549
  import { homedir as homedir8 } from "node:os";
31375
- import { renderDiff } from "@earendil-works/pi-coding-agent";
31550
+ import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve3, sep } from "node:path";
31551
+ import {
31552
+ renderDiff
31553
+ } from "@earendil-works/pi-coding-agent";
31376
31554
  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;
31555
+ import { Type as Type2 } from "typebox";
31556
+
31557
+ // src/tools/diff-format.ts
31558
+ import { diffLines } from "diff";
31559
+ var DEFAULT_CONTEXT_LINES = 4;
31560
+ function formatDiffForPi(oldContent, newContent, contextLines = DEFAULT_CONTEXT_LINES) {
31561
+ const parts = diffLines(oldContent, newContent);
31562
+ const output = [];
31563
+ const oldLines = oldContent.split(`
31564
+ `);
31565
+ const newLines = newContent.split(`
31566
+ `);
31567
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
31568
+ const lineNumWidth = String(maxLineNum).length;
31569
+ const pad = (n) => String(n).padStart(lineNumWidth, " ");
31570
+ const blank = " ".repeat(lineNumWidth);
31571
+ let oldLineNum = 1;
31572
+ let newLineNum = 1;
31573
+ let lastWasChange = false;
31574
+ let firstChangedLine;
31575
+ for (let i = 0;i < parts.length; i++) {
31576
+ const part = parts[i];
31577
+ const raw = part.value.split(`
31578
+ `);
31579
+ if (raw[raw.length - 1] === "")
31580
+ raw.pop();
31581
+ if (part.added || part.removed) {
31582
+ if (firstChangedLine === undefined)
31583
+ firstChangedLine = newLineNum;
31584
+ for (const line of raw) {
31585
+ if (part.added) {
31586
+ output.push(`+${pad(newLineNum)} ${line}`);
31587
+ newLineNum++;
31588
+ } else {
31589
+ output.push(`-${pad(oldLineNum)} ${line}`);
31590
+ oldLineNum++;
31591
+ }
31592
+ }
31593
+ lastWasChange = true;
31594
+ continue;
31595
+ }
31596
+ const nextIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
31597
+ const hasLeading = lastWasChange;
31598
+ const hasTrailing = nextIsChange;
31599
+ if (hasLeading && hasTrailing) {
31600
+ if (raw.length <= contextLines * 2) {
31601
+ for (const line of raw) {
31602
+ output.push(` ${pad(oldLineNum)} ${line}`);
31603
+ oldLineNum++;
31604
+ newLineNum++;
31605
+ }
31606
+ } else {
31607
+ for (const line of raw.slice(0, contextLines)) {
31608
+ output.push(` ${pad(oldLineNum)} ${line}`);
31609
+ oldLineNum++;
31610
+ newLineNum++;
31611
+ }
31612
+ const skipped = raw.length - contextLines * 2;
31613
+ output.push(` ${blank} ...`);
31614
+ oldLineNum += skipped;
31615
+ newLineNum += skipped;
31616
+ for (const line of raw.slice(raw.length - contextLines)) {
31617
+ output.push(` ${pad(oldLineNum)} ${line}`);
31618
+ oldLineNum++;
31619
+ newLineNum++;
31620
+ }
31621
+ }
31622
+ } else if (hasLeading) {
31623
+ const shown = raw.slice(0, contextLines);
31624
+ for (const line of shown) {
31625
+ output.push(` ${pad(oldLineNum)} ${line}`);
31626
+ oldLineNum++;
31627
+ newLineNum++;
31628
+ }
31629
+ const skipped = raw.length - shown.length;
31630
+ if (skipped > 0) {
31631
+ output.push(` ${blank} ...`);
31632
+ oldLineNum += skipped;
31633
+ newLineNum += skipped;
31634
+ }
31635
+ } else if (hasTrailing) {
31636
+ const shownCount = Math.min(contextLines, raw.length);
31637
+ const shown = raw.slice(raw.length - shownCount);
31638
+ const skipped = raw.length - shown.length;
31639
+ if (skipped > 0) {
31640
+ output.push(` ${blank} ...`);
31641
+ oldLineNum += skipped;
31642
+ newLineNum += skipped;
31643
+ }
31644
+ for (const line of shown) {
31645
+ output.push(` ${pad(oldLineNum)} ${line}`);
31646
+ oldLineNum++;
31647
+ newLineNum++;
31648
+ }
31649
+ } else {
31650
+ oldLineNum += raw.length;
31651
+ newLineNum += raw.length;
31652
+ }
31653
+ lastWasChange = false;
31654
+ }
31655
+ return { diff: output.join(`
31656
+ `), firstChangedLine };
31382
31657
  }
31383
- function shortenPath(path2) {
31384
- const home = homedir8();
31385
- if (path2.startsWith(home))
31386
- return `~${path2.slice(home.length)}`;
31387
- return path2;
31658
+
31659
+ // src/tools/hoisted.ts
31660
+ 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.";
31661
+ function diagnosticsOnEditDefault(ctx) {
31662
+ return ctx.config.lsp?.diagnostics_on_edit ?? false;
31388
31663
  }
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;
31664
+ function containsPath(parent, child) {
31665
+ const rel = relative3(parent, child);
31666
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
31394
31667
  }
31395
- function accentPath(theme, path2) {
31396
- if (!path2)
31397
- return theme.fg("toolOutput", "...");
31398
- return theme.fg("accent", shortenPath(path2));
31668
+ function expandTilde(path2) {
31669
+ if (!path2 || !path2.startsWith("~"))
31670
+ return path2;
31671
+ if (path2 === "~")
31672
+ return homedir8();
31673
+ if (path2.startsWith(`~${sep}`) || path2.startsWith("~/")) {
31674
+ return resolve3(homedir8(), path2.slice(2));
31675
+ }
31676
+ return path2;
31399
31677
  }
31400
- function collectTextContent(result) {
31401
- return result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join(`
31402
- `).trim();
31678
+ function externalDirectoryPromptTimeoutMs() {
31679
+ const raw = process.env.AFT_PI_EXTERNAL_PROMPT_TIMEOUT_MS;
31680
+ if (raw === undefined)
31681
+ return 30000;
31682
+ const parsed = Number.parseInt(raw, 10);
31683
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
31403
31684
  }
31404
- function extractStructuredPayload(result) {
31405
- if (result.details !== undefined)
31406
- return result.details;
31407
- const text = collectTextContent(result);
31408
- if (!text)
31685
+ async function assertExternalDirectoryPermission(extCtx, target, action = "modify", options = {}) {
31686
+ if (!target)
31409
31687
  return;
31410
- try {
31411
- return JSON.parse(text);
31412
- } catch {
31688
+ const expanded = expandTilde(target);
31689
+ const absoluteTarget = isAbsolute2(expanded) ? expanded : resolve3(extCtx.cwd, expanded);
31690
+ if (containsPath(extCtx.cwd, absoluteTarget))
31691
+ return;
31692
+ if (options.restrictToProjectRoot === false)
31413
31693
  return;
31694
+ const confirmFn = extCtx.ui?.confirm;
31695
+ if (extCtx.hasUI === false || !confirmFn) {
31696
+ throw new Error(`Permission denied: cannot prompt for ${action} outside the project (${absoluteTarget}).`);
31414
31697
  }
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));
31698
+ const timeoutMs = externalDirectoryPromptTimeoutMs();
31699
+ let timer;
31700
+ const timeoutPromise = new Promise((resolve4) => {
31701
+ timer = setTimeout(() => resolve4("timeout"), timeoutMs);
31434
31702
  });
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
31703
  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;
31704
+ const result = await Promise.race([
31705
+ confirmFn("Allow external directory access?", `AFT wants to ${action} outside the project: ${absoluteTarget}`),
31706
+ timeoutPromise
31707
+ ]);
31708
+ if (result === true)
31709
+ return;
31710
+ if (result === "timeout") {
31711
+ throw new Error(`Permission denied: external directory prompt timed out after ${timeoutMs}ms.`);
31525
31712
  }
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;
31713
+ throw new Error("Permission denied: external directory access was cancelled.");
31714
+ } finally {
31715
+ if (timer !== undefined)
31716
+ clearTimeout(timer);
31541
31717
  }
31542
31718
  }
31543
-
31544
- // src/tools/ast.ts
31545
- var AstLang = StringEnum(["typescript", "tsx", "javascript", "python", "rust", "go"], {
31546
- description: "Target language"
31719
+ var ReadParams = Type2.Object({
31720
+ path: Type2.String({
31721
+ description: "Path to the file to read (absolute or relative to project root)"
31722
+ }),
31723
+ offset: optionalInt(1, Number.MAX_SAFE_INTEGER),
31724
+ limit: optionalInt(1, Number.MAX_SAFE_INTEGER)
31547
31725
  });
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."
31726
+ var WriteParams = Type2.Object({
31727
+ filePath: Type2.String({
31728
+ description: "Path to the file to write (absolute or relative to project root)"
31551
31729
  }),
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" }))
31730
+ content: Type2.String({ description: "Full file contents to write" }),
31731
+ diagnostics: Type2.Optional(Type2.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
31556
31732
  });
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)" }))
31733
+ var EditParams = Type2.Object({
31734
+ filePath: Type2.String({
31735
+ description: "Path to the file to edit (absolute or relative to project root)"
31736
+ }),
31737
+ oldString: Type2.Optional(Type2.String({ description: "Text to find (exact match, fuzzy fallback)" })),
31738
+ newString: Type2.Optional(Type2.String({ description: "Replacement text (omit to delete match)" })),
31739
+ replaceAll: Type2.Optional(Type2.Boolean({ description: "Replace every occurrence" })),
31740
+ occurrence: optionalInt(0, Number.MAX_SAFE_INTEGER),
31741
+ appendContent: Type2.Optional(Type2.String({
31742
+ description: "Append text to the end of the file (creates the file if missing, parent dirs auto-created). When set, oldString/newString are ignored."
31743
+ })),
31744
+ diagnostics: Type2.Optional(Type2.Boolean({ description: DIAGNOSTICS_PARAM_DESCRIPTION }))
31564
31745
  });
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;
31746
+ var GrepParams = Type2.Object({
31747
+ pattern: Type2.String({ description: "Regex pattern to search for" }),
31748
+ path: Type2.Optional(Type2.String({
31749
+ description: "Path scope (file or directory; absolute or relative to project root)"
31750
+ })),
31751
+ include: Type2.Optional(Type2.String({ description: "Glob filter for included files (e.g. '*.ts,*.tsx')" })),
31752
+ caseSensitive: Type2.Optional(Type2.Boolean({ description: "Case-sensitive matching" })),
31753
+ contextLines: optionalInt(1, Number.MAX_SAFE_INTEGER)
31754
+ });
31755
+ function registerHoistedTools(pi, ctx, surface) {
31756
+ if (surface.hoistRead) {
31757
+ pi.registerTool({
31758
+ name: "read",
31759
+ label: "read",
31760
+ 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.",
31761
+ promptSnippet: "Read file contents (supports offset/limit for large files)",
31762
+ promptGuidelines: ["Use read to examine files instead of cat or sed."],
31763
+ parameters: ReadParams,
31764
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31765
+ const bridge = bridgeFor(ctx, extCtx.cwd);
31766
+ const offset = coerceOptionalInt(params.offset, "offset", 1, Number.MAX_SAFE_INTEGER);
31767
+ const limit = coerceOptionalInt(params.limit, "limit", 1, Number.MAX_SAFE_INTEGER);
31768
+ const req = { file: params.path };
31769
+ if (offset !== undefined) {
31770
+ req.start_line = offset;
31771
+ if (limit !== undefined) {
31772
+ req.end_line = offset + limit - 1;
31773
+ }
31774
+ } else if (limit !== undefined) {
31775
+ req.end_line = limit;
31776
+ }
31777
+ const response = await callBridge(bridge, "read", req, extCtx);
31778
+ if (Array.isArray(response.entries)) {
31779
+ return textResult(response.entries.join(`
31780
+ `));
31781
+ }
31782
+ let text = response.content ?? "";
31783
+ const agentSpecifiedRange = offset !== undefined || limit !== undefined;
31784
+ const footer = formatReadFooter(agentSpecifiedRange, response);
31785
+ if (footer)
31786
+ text += footer;
31787
+ return textResult(text);
31788
+ }
31789
+ });
31601
31790
  }
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)}`);
31791
+ if (surface.hoistWrite) {
31792
+ pi.registerTool({
31793
+ name: "write",
31794
+ label: "write",
31795
+ 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.",
31796
+ promptSnippet: "Create or overwrite files (uses filePath; auto-formats; diagnostics follow lsp.diagnostics_on_edit unless overridden)",
31797
+ promptGuidelines: ["Use write only for new files or complete rewrites."],
31798
+ parameters: WriteParams,
31799
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31800
+ await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
31801
+ restrictToProjectRoot: surface.restrictToProjectRoot
31615
31802
  });
31803
+ const bridge = bridgeFor(ctx, extCtx.cwd);
31804
+ const response = await callBridge(bridge, "write", {
31805
+ file: params.filePath,
31806
+ content: params.content,
31807
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31808
+ include_diff_content: true
31809
+ }, extCtx);
31810
+ return buildMutationResult(params.filePath, response);
31811
+ },
31812
+ renderCall(args, theme, context) {
31813
+ return renderMutationCall("write", args?.filePath, theme, context);
31814
+ },
31815
+ renderResult(result, _options, theme, context) {
31816
+ return renderMutationResult(result, theme, context);
31616
31817
  }
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
31818
  });
31624
- sections.push(lines.join(`
31625
- `));
31626
31819
  }
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
- }
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) {
31820
+ if (surface.hoistEdit) {
31692
31821
  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,
31822
+ name: "edit",
31823
+ label: "edit",
31824
+ 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.",
31825
+ 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.",
31826
+ promptGuidelines: [
31827
+ "Prefer edit over write when changing part of an existing file.",
31828
+ "Include enough surrounding context in oldString to make the match unique, or set replaceAll/occurrence explicitly.",
31829
+ "Use appendContent (instead of read+write) when adding text to the end of a file."
31830
+ ],
31831
+ parameters: EditParams,
31697
31832
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31833
+ await assertExternalDirectoryPermission(extCtx, params.filePath, "modify", {
31834
+ restrictToProjectRoot: surface.restrictToProjectRoot
31835
+ });
31698
31836
  const bridge = bridgeFor(ctx, extCtx.cwd);
31837
+ if (typeof params.appendContent === "string") {
31838
+ const req2 = {
31839
+ op: "append",
31840
+ file: params.filePath,
31841
+ append_content: params.appendContent,
31842
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31843
+ include_diff_content: true
31844
+ };
31845
+ const response2 = await callBridge(bridge, "edit_match", req2, extCtx);
31846
+ return buildMutationResult(params.filePath, response2);
31847
+ }
31699
31848
  const req = {
31700
- pattern: params.pattern,
31701
- lang: params.lang
31849
+ file: params.filePath,
31850
+ match: params.oldString ?? "",
31851
+ replacement: params.newString ?? "",
31852
+ diagnostics: params.diagnostics ?? diagnosticsOnEditDefault(ctx),
31853
+ include_diff_content: true
31702
31854
  };
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));
31855
+ if (params.replaceAll === true)
31856
+ req.replace_all = true;
31857
+ const occurrence = coerceOptionalInt(params.occurrence, "occurrence", 0, Number.MAX_SAFE_INTEGER);
31858
+ if (occurrence !== undefined)
31859
+ req.occurrence = occurrence;
31860
+ const response = await callBridge(bridge, "edit_match", req, extCtx);
31861
+ return buildMutationResult(params.filePath, response);
31711
31862
  },
31712
31863
  renderCall(args, theme, context) {
31713
- return renderAstCall("ast_grep_search", args, theme, context);
31864
+ return renderMutationCall("edit", args?.filePath, theme, context);
31714
31865
  },
31715
31866
  renderResult(result, _options, theme, context) {
31716
- return renderAstResult("ast_grep_search", result, theme, context);
31867
+ return renderMutationResult(result, theme, context);
31717
31868
  }
31718
31869
  });
31719
31870
  }
31720
- if (surface.astReplace) {
31871
+ if (surface.hoistGrep) {
31721
31872
  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,
31873
+ name: "grep",
31874
+ label: "grep",
31875
+ 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.",
31876
+ promptSnippet: "Fast regex search across files (trigram-indexed inside the project root)",
31877
+ promptGuidelines: ["Prefer grep over bash-invoked find/rg for in-project searches."],
31878
+ parameters: GrepParams,
31726
31879
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
31727
31880
  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);
31881
+ const req = { pattern: params.pattern };
31882
+ if (params.path) {
31883
+ await assertExternalDirectoryPermission(extCtx, params.path, "search", {
31884
+ restrictToProjectRoot: surface.restrictToProjectRoot
31885
+ });
31886
+ req.path = await resolvePathArg(extCtx.cwd, params.path);
31887
+ }
31888
+ if (params.include)
31889
+ req.include = splitIncludeGlobs(params.include);
31890
+ if (params.caseSensitive !== undefined)
31891
+ req.case_sensitive = params.caseSensitive;
31892
+ const contextLines = coerceOptionalInt(params.contextLines, "contextLines", 1, Number.MAX_SAFE_INTEGER);
31893
+ if (contextLines !== undefined)
31894
+ req.context_lines = contextLines;
31895
+ const response = await callBridge(bridge, "grep", req, extCtx);
31896
+ const text = response.text ?? "";
31897
+ return textResult(text);
31746
31898
  }
31747
31899
  });
31748
31900
  }
31749
31901
  }
31902
+ function buildMutationResult(filePath, response) {
31903
+ const diffObj = response.diff;
31904
+ const additions = diffObj?.additions ?? 0;
31905
+ const deletions = diffObj?.deletions ?? 0;
31906
+ const replacements = response.replacements;
31907
+ const diagnostics = response.lsp_diagnostics;
31908
+ const truncated = diffObj?.truncated === true;
31909
+ const noOp = response.no_op === true;
31910
+ const formatted = response.formatted;
31911
+ const formatSkippedReason = response.format_skipped_reason;
31912
+ const globFormatSkipReasons = response.format_skip_reasons;
31913
+ let diffText;
31914
+ let firstChangedLine;
31915
+ if (diffObj && !truncated && typeof diffObj.before === "string" && typeof diffObj.after === "string") {
31916
+ const piDiff = formatDiffForPi(diffObj.before, diffObj.after);
31917
+ diffText = piDiff.diff;
31918
+ firstChangedLine = piDiff.firstChangedLine;
31919
+ }
31920
+ const summaryHeader = replacements !== undefined ? `Edited ${filePath} (+${additions}/-${deletions}, ${replacements} replacement${replacements === 1 ? "" : "s"})` : `Wrote ${filePath} (+${additions}/-${deletions})`;
31921
+ let text = summaryHeader;
31922
+ if (noOp) {
31923
+ text += `
31750
31924
 
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");
31925
+ 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
31926
  }
31843
- return api2.hooks?.bashSpawn;
31844
- }
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);
31927
+ const skipNote = formatSkipReasonNote(formatSkippedReason);
31928
+ if (skipNote)
31929
+ text += `
31930
+
31931
+ ${skipNote}`;
31932
+ const globSkipNote = formatGlobSkipReasonsNote(globFormatSkipReasons);
31933
+ if (globSkipNote)
31934
+ text += `
31935
+
31936
+ ${globSkipNote}`;
31937
+ if (diagnostics && diagnostics.length > 0) {
31938
+ text += `
31939
+
31940
+ LSP diagnostics:
31941
+ ${formatDiagnosticsText(diagnostics)}`;
31942
+ }
31943
+ return {
31944
+ content: [{ type: "text", text }],
31945
+ details: {
31946
+ diff: diffText,
31947
+ firstChangedLine,
31948
+ additions,
31949
+ deletions,
31950
+ replacements,
31951
+ diagnostics,
31952
+ truncated: truncated || undefined,
31953
+ formatted,
31954
+ formatSkippedReason,
31955
+ noOp: noOp || undefined
31956
31956
  }
31957
- });
31958
- pi.registerTool(createBashStatusTool(ctx));
31959
- pi.registerTool(createBashWatchTool(ctx));
31960
- pi.registerTool(createBashWriteTool(ctx));
31961
- pi.registerTool(createBashKillTool(ctx));
31957
+ };
31962
31958
  }
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.`;
31966
- }
31967
- return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
31959
+ function formatGlobSkipReasonsNote(reasons) {
31960
+ if (!Array.isArray(reasons))
31961
+ return;
31962
+ const actionable = reasons.filter((reason) => typeof reason === "string").filter((reason) => ["formatter_not_installed", "formatter_excluded_path", "timeout", "error"].includes(reason));
31963
+ if (actionable.length === 0)
31964
+ return;
31965
+ return `Note: formatter skipped some glob edit result file(s): ${[...new Set(actionable)].sort().join(", ")}. See per-file format_skipped_reason values for details.`;
31968
31966
  }
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.`;
31967
+ function formatSkipReasonNote(reason) {
31968
+ switch (reason) {
31969
+ case "formatter_not_installed":
31970
+ return "Note: formatter binary not installed; file written unformatted.";
31971
+ case "timeout":
31972
+ return "Note: formatter timed out; file written unformatted. Raise formatter_timeout_secs or check the formatter for hangs.";
31973
+ case "formatter_excluded_path":
31974
+ return "Note: formatter is configured to ignore this path (e.g. biome.json files.includes, .prettierignore). File written unformatted.";
31975
+ case "error":
31976
+ return "Note: formatter exited with an unrecognized error; file written unformatted.";
31977
+ default:
31978
+ return;
31979
+ }
31972
31980
  }
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}]`;
31981
+ function formatDiagnosticsText(diagnostics) {
31982
+ try {
31983
+ return diagnostics.map((d) => {
31984
+ if (d && typeof d === "object") {
31985
+ const obj = d;
31986
+ const line = obj.line ?? obj.startLine ?? "?";
31987
+ const severity = obj.severity ?? "info";
31988
+ const msg = obj.message ?? JSON.stringify(obj);
31989
+ return ` [${severity}] line ${line}: ${msg}`;
31990
+ }
31991
+ return ` ${String(d)}`;
31992
+ }).join(`
31993
+ `);
31994
+ } catch {
31995
+ return JSON.stringify(diagnostics, null, 2);
31983
31996
  }
31984
- if (status === "timed_out") {
31985
- rendered += `
31986
- [command timed out]`;
31997
+ }
31998
+ function reuseText(last) {
31999
+ return last instanceof Text ? last : new Text("", 0, 0);
32000
+ }
32001
+ function reuseContainer(last) {
32002
+ return last instanceof Container ? last : new Container;
32003
+ }
32004
+ function renderMutationCall(toolName, filePath, theme, context) {
32005
+ const text = reuseText(context.lastComponent);
32006
+ const pathDisplay = filePath ? theme.fg("accent", shortenPath(filePath)) : theme.fg("toolOutput", "...");
32007
+ text.setText(`${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`);
32008
+ return text;
32009
+ }
32010
+ function renderMutationResult(result, theme, context) {
32011
+ if (context.isError) {
32012
+ const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
32013
+ `).trim();
32014
+ const text = reuseText(context.lastComponent);
32015
+ text.setText(`
32016
+ ${theme.fg("error", errorText || "edit failed")}`);
32017
+ return text;
31987
32018
  }
31988
- if (typeof exit === "number" && exit !== 0) {
31989
- rendered += `
31990
- [exit code: ${exit}]`;
32019
+ const details = result.details;
32020
+ const diff = typeof details?.diff === "string" ? details.diff : undefined;
32021
+ if (!diff) {
32022
+ const additions = details?.additions ?? 0;
32023
+ const deletions = details?.deletions ?? 0;
32024
+ const text = reuseText(context.lastComponent);
32025
+ const summary = theme.fg("success", `+${additions}/-${deletions}`);
32026
+ let suffix = "";
32027
+ if (details?.truncated) {
32028
+ suffix = ` ${theme.fg("muted", "(diff truncated)")}`;
32029
+ } else if (details?.noOp) {
32030
+ suffix = ` ${theme.fg("muted", "(no net change)")}`;
32031
+ }
32032
+ text.setText(`
32033
+ ${summary}${suffix}`);
32034
+ return text;
31991
32035
  }
31992
- return rendered;
32036
+ const container = reuseContainer(context.lastComponent);
32037
+ container.clear();
32038
+ container.addChild(new Spacer(1));
32039
+ container.addChild(new Text(renderDiff(diff), 1, 0));
32040
+ return container;
31993
32041
  }
31994
- function sleep(ms) {
31995
- return new Promise((resolve3) => setTimeout(resolve3, ms));
32042
+ function shortenPath(path2) {
32043
+ const home = homedir8();
32044
+ if (path2.startsWith(home))
32045
+ return `~${path2.slice(home.length)}`;
32046
+ return path2;
31996
32047
  }
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);
32009
- }
32010
- };
32048
+ async function resolvePathArg(cwd, path2) {
32049
+ const expanded = expandTilde(path2);
32050
+ const abs = isAbsolute2(expanded) ? expanded : resolve3(cwd, expanded);
32051
+ try {
32052
+ await stat(abs);
32053
+ return abs;
32054
+ } catch {
32055
+ return expanded;
32056
+ }
32011
32057
  }
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);
32058
+ function splitIncludeGlobs(include) {
32059
+ const out = [];
32060
+ let depth = 0;
32061
+ let buf = "";
32062
+ for (const ch of include) {
32063
+ if (ch === "{") {
32064
+ depth++;
32065
+ buf += ch;
32066
+ continue;
32056
32067
  }
32057
- };
32058
- }
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);
32068
+ if (ch === "}") {
32069
+ if (depth > 0)
32070
+ depth--;
32071
+ buf += ch;
32072
+ continue;
32070
32073
  }
32071
- };
32072
- }
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);
32074
+ if (ch === "," && depth === 0) {
32075
+ const trimmed = buf.trim();
32076
+ if (trimmed.length > 0)
32077
+ out.push(trimmed);
32078
+ buf = "";
32079
+ continue;
32092
32080
  }
32093
- };
32081
+ buf += ch;
32082
+ }
32083
+ const tail2 = buf.trim();
32084
+ if (tail2.length > 0)
32085
+ out.push(tail2);
32086
+ return out;
32094
32087
  }
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
- };
32088
+ function formatReadFooter(agentSpecifiedRange, data) {
32089
+ if (agentSpecifiedRange)
32090
+ return "";
32091
+ if (!data.truncated)
32092
+ return "";
32093
+ const startLine = data.start_line;
32094
+ const endLine = data.end_line;
32095
+ const totalLines = data.total_lines;
32096
+ if (startLine === undefined || endLine === undefined || totalLines === undefined) {
32097
+ return "";
32098
+ }
32099
+ return `
32100
+ (Showing lines ${startLine}-${endLine} of ${totalLines}. Use offset/limit to read other sections.)`;
32107
32101
  }
32108
- function bashStatusResult(output, details) {
32109
- return {
32110
- content: [{ type: "text", text: output }],
32111
- details
32112
- };
32102
+
32103
+ // src/tools/render-helpers.ts
32104
+ import { homedir as homedir9 } from "node:os";
32105
+ import { renderDiff as renderDiff2 } from "@earendil-works/pi-coding-agent";
32106
+ import { Container as Container2, Spacer as Spacer2, Text as Text2 } from "@earendil-works/pi-tui";
32107
+ function reuseText2(last) {
32108
+ return last instanceof Text2 ? last : new Text2("", 0, 0);
32113
32109
  }
32114
- function bashKillResult(output, details) {
32115
- return {
32116
- content: [{ type: "text", text: output }],
32117
- details
32118
- };
32110
+ function reuseContainer2(last) {
32111
+ return last instanceof Container2 ? last : new Container2;
32119
32112
  }
32120
- async function bashStatusSnapshot(bridge, extCtx, taskId, outputMode, options) {
32121
- return await callBridge(bridge, "bash_status", { task_id: taskId, output_mode: outputMode }, extCtx, options);
32113
+ function shortenPath2(path2) {
32114
+ const home = homedir9();
32115
+ if (path2.startsWith(home))
32116
+ return `~${path2.slice(home.length)}`;
32117
+ return path2;
32122
32118
  }
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;
32137
- 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
- }
32183
- }
32119
+ function renderToolCall(toolName, summary, theme, context) {
32120
+ const text = reuseText2(context.lastComponent);
32121
+ const suffix = summary ? ` ${summary}` : "";
32122
+ text.setText(`${theme.fg("toolTitle", theme.bold(toolName))}${suffix}`);
32123
+ return text;
32184
32124
  }
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)
32125
+ function accentPath(theme, path2) {
32126
+ if (!path2)
32127
+ return theme.fg("toolOutput", "...");
32128
+ return theme.fg("accent", shortenPath2(path2));
32129
+ }
32130
+ function collectTextContent(result) {
32131
+ return result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join(`
32132
+ `).trim();
32133
+ }
32134
+ function extractStructuredPayload(result) {
32135
+ if (result.details !== undefined)
32136
+ return result.details;
32137
+ const text = collectTextContent(result);
32138
+ if (!text)
32204
32139
  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)
32140
+ try {
32141
+ return JSON.parse(text);
32142
+ } catch {
32209
32143
  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
- };
32219
- }
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
32144
  }
32239
32145
  }
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;
32146
+ function renderErrorResult(result, fallback, theme, context) {
32147
+ const text = reuseText2(context.lastComponent);
32148
+ const message = collectTextContent(result) || fallback;
32149
+ text.setText(`
32150
+ ${theme.fg("error", message)}`);
32151
+ return text;
32246
32152
  }
32247
- function isRegexWaitObject(value) {
32248
- return typeof value === "object" && value !== null && "regex" in value && typeof value.regex === "string";
32153
+ function renderSections(sections, context) {
32154
+ const container = reuseContainer2(context.lastComponent);
32155
+ container.clear();
32156
+ const visibleSections = sections.filter((section) => section.trim().length > 0);
32157
+ if (visibleSections.length === 0)
32158
+ return container;
32159
+ container.addChild(new Spacer2(1));
32160
+ visibleSections.forEach((section, index) => {
32161
+ if (index > 0)
32162
+ container.addChild(new Spacer2(1));
32163
+ container.addChild(new Text2(section, 0, 0));
32164
+ });
32165
+ return container;
32249
32166
  }
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;
32167
+ function asRecord2(value) {
32168
+ if (!value || typeof value !== "object" || Array.isArray(value))
32169
+ return;
32170
+ return value;
32258
32171
  }
32259
- function withWaited(data, waited) {
32260
- return { ...data, waited };
32172
+ function asRecords(value) {
32173
+ return Array.isArray(value) ? value.map(asRecord2).filter(Boolean) : [];
32261
32174
  }
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.`;
32175
+ function asString(value) {
32176
+ return typeof value === "string" ? value : undefined;
32177
+ }
32178
+ function asNumber(value) {
32179
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
32180
+ }
32181
+ function asBoolean(value) {
32182
+ return typeof value === "boolean" ? value : undefined;
32183
+ }
32184
+ function formatValue(value) {
32185
+ if (Array.isArray(value))
32186
+ return value.map(formatValue).join(", ");
32187
+ if (typeof value === "string")
32188
+ return value;
32189
+ if (typeof value === "number" || typeof value === "boolean")
32190
+ return String(value);
32191
+ if (value === null || value === undefined)
32192
+ return "—";
32193
+ try {
32194
+ return JSON.stringify(value);
32195
+ } catch {
32196
+ return String(value);
32268
32197
  }
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
32198
  }
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)}`;
32199
+ function groupByFile(items, getFile) {
32200
+ const groups = new Map;
32201
+ items.forEach((item) => {
32202
+ const file2 = getFile(item) ?? "(unknown file)";
32203
+ const current = groups.get(file2) ?? [];
32204
+ current.push(item);
32205
+ groups.set(file2, current);
32206
+ });
32207
+ return groups;
32208
+ }
32209
+ function formatTimestamp(value) {
32210
+ if (typeof value === "string" && value.length > 0)
32211
+ return value;
32212
+ if (typeof value !== "number" || !Number.isFinite(value))
32213
+ return;
32214
+ const millis = value > 1000000000000 ? value : value * 1000;
32215
+ const date5 = new Date(millis);
32216
+ if (Number.isNaN(date5.getTime()))
32217
+ return String(value);
32218
+ return date5.toISOString().replace("T", " ").replace(".000Z", "Z");
32219
+ }
32220
+ function formatUnifiedDiffForPi(unifiedDiff) {
32221
+ if (!unifiedDiff.trim())
32222
+ return "";
32223
+ const entries = [];
32224
+ const hunkHeader = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
32225
+ let oldLine = 1;
32226
+ let newLine = 1;
32227
+ for (const line of unifiedDiff.split(`
32228
+ `)) {
32229
+ if (!line)
32230
+ continue;
32231
+ if (line.startsWith("--- ") || line.startsWith("+++ "))
32232
+ continue;
32233
+ if (line.startsWith("\"))
32234
+ continue;
32235
+ const headerMatch = line.match(hunkHeader);
32236
+ if (headerMatch) {
32237
+ oldLine = Number(headerMatch[1]);
32238
+ newLine = Number(headerMatch[2]);
32239
+ continue;
32285
32240
  }
32286
- if (!isTerminalStatus(details.status)) {
32287
- text += `
32288
- A completion reminder will be delivered automatically; don't poll.`;
32241
+ if (line.startsWith("+") && !line.startsWith("+++")) {
32242
+ entries.push({ prefix: "+", line: newLine, text: line.slice(1) });
32243
+ newLine += 1;
32244
+ continue;
32245
+ }
32246
+ if (line.startsWith("-") && !line.startsWith("---")) {
32247
+ entries.push({ prefix: "-", line: oldLine, text: line.slice(1) });
32248
+ oldLine += 1;
32249
+ continue;
32250
+ }
32251
+ if (line.startsWith(" ")) {
32252
+ entries.push({ prefix: " ", line: oldLine, text: line.slice(1) });
32253
+ oldLine += 1;
32254
+ newLine += 1;
32289
32255
  }
32290
32256
  }
32291
- return text;
32257
+ if (entries.length === 0)
32258
+ return "";
32259
+ const width = String(entries.reduce((max, entry) => Math.max(max, entry.line), 1)).length;
32260
+ return entries.map((entry) => `${entry.prefix}${String(entry.line).padStart(width, " ")} ${entry.text}`).join(`
32261
+ `);
32292
32262
  }
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);
32263
+ function renderUnifiedDiff(unifiedDiff) {
32264
+ const piDiff = formatUnifiedDiffForPi(unifiedDiff);
32265
+ if (!piDiff)
32266
+ return "";
32267
+ try {
32268
+ return renderDiff2(piDiff);
32269
+ } catch {
32270
+ return piDiff;
32319
32271
  }
32320
- return suffix;
32321
32272
  }
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}`;
32273
+
32274
+ // src/tools/ast.ts
32275
+ var AstLang = StringEnum(["typescript", "tsx", "javascript", "python", "rust", "go"], {
32276
+ description: "Target language"
32277
+ });
32278
+ var SearchParams = Type3.Object({
32279
+ pattern: Type3.String({
32280
+ description: "AST pattern with meta-variables (`$VAR` matches one node, `$$$` matches many). Must be a complete AST node."
32281
+ }),
32282
+ lang: AstLang,
32283
+ paths: Type3.Optional(Type3.Array(Type3.String(), { description: "Paths to search (default: ['.'])" })),
32284
+ globs: Type3.Optional(Type3.Array(Type3.String(), { description: "Include/exclude globs (prefix `!` to exclude)" })),
32285
+ contextLines: Type3.Optional(Type3.Number({ description: "Number of context lines around each match" }))
32286
+ });
32287
+ var ReplaceParams = Type3.Object({
32288
+ pattern: Type3.String({ description: "AST pattern with meta-variables" }),
32289
+ rewrite: Type3.String({ description: "Replacement pattern, can reference $VAR from pattern" }),
32290
+ lang: AstLang,
32291
+ paths: Type3.Optional(Type3.Array(Type3.String(), { description: "Paths (default: ['.'])" })),
32292
+ globs: Type3.Optional(Type3.Array(Type3.String(), { description: "Include/exclude globs" })),
32293
+ dryRun: Type3.Optional(Type3.Boolean({ description: "Preview without applying (default: false)" }))
32294
+ });
32295
+ function appendHintSection(response, sections, theme) {
32296
+ const hint = asString(response.hint);
32297
+ if (hint && hint.length > 0) {
32298
+ sections.push(theme.fg("warning", hint));
32299
+ }
32329
32300
  }
32330
- function watchPtyCacheKey(extCtx, taskId) {
32331
- return `${ptyCacheKey(extCtx, taskId)}::watch`;
32301
+ function appendScopeSections(response, sections, theme) {
32302
+ if (response.no_files_matched_scope === true) {
32303
+ sections.push(theme.fg("warning", "No files matched the scope (paths/globs resolved to zero files)"));
32304
+ }
32305
+ const warnings = asRecords(response.scope_warnings);
32306
+ const warningStrings = Array.isArray(response.scope_warnings) ? response.scope_warnings.filter((w) => typeof w === "string") : warnings.map((w) => asString(w.warning) ?? "").filter(Boolean);
32307
+ if (warningStrings.length > 0) {
32308
+ sections.push(`${theme.fg("muted", "Scope warnings:")}
32309
+ ${warningStrings.map((w) => ` ${w}`).join(`
32310
+ `)}`);
32311
+ }
32332
32312
  }
32333
- function isTerminalStatus(status) {
32334
- return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
32313
+ async function resolveAstPaths(extCtx, paths) {
32314
+ if (isEmptyParam(paths) || !Array.isArray(paths))
32315
+ return;
32316
+ return Promise.all(paths.filter((path2) => typeof path2 === "string").map((path2) => resolvePathArg(extCtx.cwd, path2)));
32335
32317
  }
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;
32318
+ async function assertAstPathsPermission(extCtx, paths, action, restrictToProjectRoot) {
32319
+ if (paths === undefined || paths.length === 0)
32320
+ return;
32321
+ const checked = new Set;
32322
+ for (const path2 of paths) {
32323
+ if (checked.has(path2))
32324
+ continue;
32325
+ checked.add(path2);
32326
+ await assertExternalDirectoryPermission(extCtx, path2, action, { restrictToProjectRoot });
32327
+ }
32341
32328
  }
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));
32367
- }
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));
32329
+ function buildAstSearchSections(payload, theme) {
32330
+ const response = asRecord2(payload);
32331
+ if (!response)
32332
+ return [theme.fg("muted", "No AST search results.")];
32333
+ const matches = asRecords(response.matches);
32334
+ const totalMatches = asNumber(response.total_matches) ?? matches.length;
32335
+ const filesWithMatches = asNumber(response.files_with_matches) ?? groupByFile(matches, (match) => asString(match.file)).size;
32336
+ const filesSearched = asNumber(response.files_searched);
32337
+ const header = [
32338
+ theme.fg("success", `${totalMatches} match${totalMatches === 1 ? "" : "es"}`),
32339
+ theme.fg("accent", `${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`),
32340
+ filesSearched !== undefined ? theme.fg("muted", `${filesSearched} searched`) : undefined
32341
+ ].filter(Boolean).join(" · ");
32342
+ if (matches.length === 0) {
32343
+ const sections2 = [header, theme.fg("muted", "No AST matches found.")];
32344
+ appendScopeSections(response, sections2, theme);
32345
+ appendHintSection(response, sections2, theme);
32346
+ return sections2;
32388
32347
  }
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));
32348
+ const grouped = groupByFile(matches, (match) => asString(match.file));
32349
+ const sections = [header];
32350
+ for (const [file2, fileMatches] of grouped.entries()) {
32351
+ const lines = [theme.fg("accent", shortenPath2(file2))];
32352
+ fileMatches.forEach((match, index) => {
32353
+ const line = asNumber(match.line) ?? 0;
32354
+ const column = asNumber(match.column) ?? 0;
32355
+ const snippet = asString(match.text)?.trim() || "(empty match)";
32356
+ lines.push(` ${index + 1}. ${theme.fg("muted", `${line}:${column}`)} ${snippet}`);
32357
+ const metaVars = asRecord2(match.meta_variables);
32358
+ if (metaVars && Object.keys(metaVars).length > 0) {
32359
+ Object.entries(metaVars).forEach(([name, value]) => {
32360
+ lines.push(` ${theme.fg("muted", `${name} =`)} ${formatValue(value)}`);
32361
+ });
32362
+ }
32363
+ const context = asRecords(match.context);
32364
+ context.forEach((ctxLine) => {
32365
+ const ctxNumber = asNumber(ctxLine.line) ?? 0;
32366
+ const prefix = ctxLine.is_match === true ? theme.fg("accent", ">") : theme.fg("muted", "|");
32367
+ lines.push(` ${prefix} ${ctxNumber}: ${asString(ctxLine.text) ?? ""}`);
32368
+ });
32369
+ });
32370
+ sections.push(lines.join(`
32371
+ `));
32393
32372
  }
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);
32373
+ return sections;
32407
32374
  }
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
32375
+ function buildAstReplaceSections(payload, theme) {
32376
+ const response = asRecord2(payload);
32377
+ if (!response)
32378
+ return [theme.fg("muted", "No AST replace results.")];
32379
+ const files = asRecords(response.files);
32380
+ const totalReplacements = asNumber(response.total_replacements) ?? 0;
32381
+ const totalFiles = asNumber(response.total_files) ?? files.length;
32382
+ const filesWithMatches = asNumber(response.files_with_matches);
32383
+ const dryRun = response.dry_run === true;
32384
+ const headerParts = [
32385
+ dryRun ? theme.fg("warning", "[dry run]") : theme.fg("success", "[applied]"),
32386
+ `${totalReplacements} replacement${totalReplacements === 1 ? "" : "s"}`,
32387
+ `${totalFiles} file${totalFiles === 1 ? "" : "s"}`,
32388
+ filesWithMatches !== undefined ? theme.fg("muted", `${filesWithMatches} matched`) : undefined
32416
32389
  ];
32417
- if (rest.length === 0)
32390
+ const sections = [headerParts.filter(Boolean).join(" ")];
32391
+ if (files.length === 0) {
32392
+ sections.push(theme.fg("muted", "No files changed."));
32393
+ appendScopeSections(response, sections, theme);
32394
+ appendHintSection(response, sections, theme);
32418
32395
  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);
32396
+ }
32397
+ files.forEach((fileResult) => {
32398
+ const file2 = shortenPath2(asString(fileResult.file) ?? "(unknown file)");
32399
+ const replacements = asNumber(fileResult.replacements) ?? 0;
32400
+ const error50 = asString(fileResult.error);
32401
+ const diff = asString(fileResult.diff);
32402
+ const lines = [
32403
+ `${theme.fg("accent", file2)} ${theme.fg("muted", `(${replacements} replacement${replacements === 1 ? "" : "s"})`)}`
32404
+ ];
32405
+ if (error50) {
32406
+ lines.push(theme.fg("error", error50));
32407
+ } else if (diff) {
32408
+ const rendered = renderUnifiedDiff(diff);
32409
+ lines.push(rendered || theme.fg("muted", "No diff available."));
32410
+ } else {
32411
+ const backupId = asString(fileResult.backup_id);
32412
+ lines.push(backupId ? `${theme.fg("success", "saved")} ${theme.fg("muted", backupId)}` : theme.fg("success", "saved"));
32447
32413
  }
32414
+ sections.push(lines.join(`
32415
+ `));
32448
32416
  });
32417
+ return sections;
32449
32418
  }
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);
32419
+ function renderAstCall(toolName, args, theme, context) {
32420
+ const lang = theme.fg("accent", args.lang);
32421
+ const summary = toolName === "ast_grep_replace" ? `${lang} ${theme.fg("toolOutput", `${args.pattern} ${args.rewrite}`)}` : `${lang} ${theme.fg("toolOutput", args.pattern)}`;
32422
+ return renderToolCall(toolName === "ast_grep_replace" ? "ast replace" : "ast search", summary, theme, context);
32478
32423
  }
32479
- function renderFsResult(toolName, args, result, theme, context) {
32424
+ function renderAstResult(toolName, result, theme, context) {
32480
32425
  if (context.isError) {
32481
32426
  return renderErrorResult(result, `${toolName} failed`, theme, context);
32482
32427
  }
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);
32428
+ const payload = extractStructuredPayload(result);
32429
+ if (!payload) {
32430
+ const text = collectTextContent(result);
32431
+ return renderSections([text || theme.fg("muted", "No result.")], context);
32500
32432
  }
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);
32433
+ const sections = toolName === "ast_grep_replace" ? buildAstReplaceSections(payload, theme) : buildAstSearchSections(payload, theme);
32434
+ return renderSections(sections, context);
32506
32435
  }
32507
- function registerFsTools(pi, ctx, surface) {
32508
- if (surface.delete) {
32436
+ function registerAstTools(pi, ctx, surface) {
32437
+ if (surface.astSearch) {
32509
32438
  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,
32439
+ name: "ast_grep_search",
32440
+ label: "ast search",
32441
+ 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.).",
32442
+ parameters: SearchParams,
32514
32443
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32444
+ const paths = await resolveAstPaths(extCtx, params.paths);
32445
+ await assertAstPathsPermission(extCtx, paths, "search", ctx.config.restrict_to_project_root ?? false);
32515
32446
  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
- });
32535
- },
32536
- renderCall(args, theme, context) {
32537
- return renderFsCall("aft_delete", args, theme, context);
32538
- },
32539
- renderResult(result, _options, theme, context) {
32540
- return renderFsResult("aft_delete", context.args, result, theme, context);
32541
- }
32542
- });
32447
+ const req = {
32448
+ pattern: params.pattern,
32449
+ lang: params.lang
32450
+ };
32451
+ if (!isEmptyParam(paths))
32452
+ req.paths = paths;
32453
+ if (!isEmptyParam(params.globs))
32454
+ req.globs = params.globs;
32455
+ if (params.contextLines !== undefined)
32456
+ req.context_lines = params.contextLines;
32457
+ const response = await callBridge(bridge, "ast_search", req, extCtx);
32458
+ return textResult(response.text ?? JSON.stringify(response));
32459
+ },
32460
+ renderCall(args, theme, context) {
32461
+ return renderAstCall("ast_grep_search", args, theme, context);
32462
+ },
32463
+ renderResult(result, _options, theme, context) {
32464
+ return renderAstResult("ast_grep_search", result, theme, context);
32465
+ }
32466
+ });
32543
32467
  }
32544
- if (surface.move) {
32468
+ if (surface.astReplace) {
32545
32469
  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,
32470
+ name: "ast_grep_replace",
32471
+ label: "ast replace",
32472
+ 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.",
32473
+ parameters: ReplaceParams,
32550
32474
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32475
+ const paths = await resolveAstPaths(extCtx, params.paths);
32476
+ await assertAstPathsPermission(extCtx, paths, params.dryRun === true ? "search" : "modify", ctx.config.restrict_to_project_root ?? false);
32551
32477
  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);
32478
+ const req = {
32479
+ pattern: params.pattern,
32480
+ rewrite: params.rewrite,
32481
+ lang: params.lang
32482
+ };
32483
+ if (!isEmptyParam(paths))
32484
+ req.paths = paths;
32485
+ if (!isEmptyParam(params.globs))
32486
+ req.globs = params.globs;
32487
+ req.dry_run = params.dryRun === true;
32488
+ const response = await callBridge(bridge, "ast_replace", req, extCtx);
32489
+ return textResult(response.text ?? JSON.stringify(response));
32557
32490
  },
32558
32491
  renderCall(args, theme, context) {
32559
- return renderFsCall("aft_move", args, theme, context);
32492
+ return renderAstCall("ast_grep_replace", args, theme, context);
32560
32493
  },
32561
32494
  renderResult(result, _options, theme, context) {
32562
- return renderFsResult("aft_move", context.args, result, theme, context);
32495
+ return renderAstResult("ast_grep_replace", result, theme, context);
32563
32496
  }
32564
32497
  });
32565
32498
  }
32566
32499
  }
32567
32500
 
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";
32501
+ // src/tools/bash.ts
32502
+ import * as fs3 from "node:fs/promises";
32575
32503
  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(`
32504
+ import { Type as Type4 } from "typebox";
32505
+ var FOREGROUND_POLL_INTERVAL_MS = 100;
32506
+ var BASH_WAIT_POLL_INTERVAL_MS = 100;
32507
+ var DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS = 30000;
32508
+ var MAX_BASH_STATUS_WAIT_TIMEOUT_MS = 300000;
32509
+ var BASH_TRANSPORT_TIMEOUT_MS = 30000;
32510
+ var BashParams = Type4.Object({
32511
+ command: Type4.String({
32512
+ description: "Shell command to execute. Supports pipes, redirections, and shell syntax."
32513
+ }),
32514
+ timeout: optionalInt(1, Number.MAX_SAFE_INTEGER),
32515
+ workdir: Type4.Optional(Type4.String({
32516
+ description: "Working directory for command execution. Relative paths resolve against the project root. Defaults to the current session's working directory."
32517
+ })),
32518
+ description: Type4.Optional(Type4.String({
32519
+ description: "Human-readable description shown in UI logs. Helps users understand what the command does without reading shell syntax."
32520
+ })),
32521
+ background: Type4.Optional(Type4.Boolean({
32522
+ 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."
32523
+ })),
32524
+ compressed: Type4.Optional(Type4.Boolean({
32525
+ 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."
32526
+ })),
32527
+ pty: Type4.Optional(Type4.Boolean({
32528
+ 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.'
32529
+ })),
32530
+ ptyRows: optionalInt(1, 60),
32531
+ ptyCols: optionalInt(1, 140)
32532
+ });
32533
+ var BashTaskParams = Type4.Object({
32534
+ task_id: Type4.String({
32535
+ description: "Background bash task id returned by bash({ background: true })."
32536
+ })
32537
+ });
32538
+ var BashStatusParams = Type4.Object({
32539
+ task_id: Type4.String({
32540
+ description: "Background bash task id returned by bash({ background: true })."
32541
+ }),
32542
+ output_mode: Type4.Optional(Type4.Union([Type4.Literal("screen"), Type4.Literal("raw"), Type4.Literal("both")], {
32543
+ description: "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted."
32544
+ }))
32545
+ });
32546
+ var BashWatchParams = Type4.Object({
32547
+ task_id: Type4.String({
32548
+ description: "Background bash task id returned by bash({ background: true })."
32549
+ }),
32550
+ pattern: Type4.Optional(Type4.Union([Type4.String(), Type4.Object({ regex: Type4.String() })])),
32551
+ background: Type4.Optional(Type4.Boolean()),
32552
+ timeout_ms: optionalInt(1, MAX_BASH_STATUS_WAIT_TIMEOUT_MS),
32553
+ once: Type4.Optional(Type4.Boolean())
32554
+ });
32555
+ var BashWriteParams = Type4.Object({
32556
+ task_id: Type4.String({
32557
+ description: "Background PTY task id returned by bash({ pty: true, background: true })."
32558
+ }),
32559
+ input: Type4.Union([
32560
+ Type4.String(),
32561
+ Type4.Array(Type4.Union([
32562
+ Type4.String(),
32563
+ Type4.Object({
32564
+ key: Type4.String({
32565
+ description: "Named control key, e.g. 'esc', 'enter', 'up', 'ctrl-c'. Case-insensitive."
32566
+ })
32567
+ })
32568
+ ]))
32569
+ ], {
32570
+ 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."
32571
+ })
32572
+ });
32573
+ function truncateToVisualLines(text, maxLines) {
32574
+ const lines = text.split(`
32587
32575
  `);
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(`
32576
+ if (lines.length <= maxLines)
32577
+ return text;
32578
+ return lines.slice(-maxLines).join(`
32599
32579
  `);
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++;
32612
- }
32613
- }
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++;
32632
- }
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++;
32641
- }
32642
- }
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;
32664
- }
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;
32673
- }
32674
- lastWasChange = false;
32675
- }
32676
- return { diff: output.join(`
32677
- `), firstChangedLine };
32678
32580
  }
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;
32581
+ function reuseText3(last) {
32582
+ return last instanceof Text3 ? last : new Text3("", 0, 0);
32684
32583
  }
32685
- function containsPath(parent, child) {
32686
- const rel = relative3(parent, child);
32687
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
32584
+ function reuseContainer3(last) {
32585
+ return last instanceof Container3 ? last : new Container3;
32688
32586
  }
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));
32587
+ function getBashSpawnHook(pi) {
32588
+ const api2 = pi;
32589
+ if (typeof api2.getHook === "function") {
32590
+ return api2.getHook("bashSpawn");
32696
32591
  }
32697
- return path2;
32698
- }
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;
32592
+ return api2.hooks?.bashSpawn;
32705
32593
  }
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.`);
32594
+ function registerBashTool(pi, ctx) {
32595
+ const spawnHook = getBashSpawnHook(pi);
32596
+ pi.registerTool({
32597
+ name: "bash",
32598
+ label: "bash",
32599
+ 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`.',
32600
+ promptSnippet: "Run shell commands (timeout in milliseconds; supports workdir, background tasks, compressed output, PTY mode)",
32601
+ promptGuidelines: [
32602
+ "Use bash only when a dedicated AFT tool is not a better fit.",
32603
+ "Set compressed: false when you need ANSI color codes in the output."
32604
+ ],
32605
+ parameters: BashParams,
32606
+ async execute(_toolCallId, params, _signal, onUpdate, extCtx) {
32607
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32608
+ const foregroundWaitMs = resolveBashConfig(ctx.config).foreground_wait_window_ms;
32609
+ const timeout = coerceOptionalInt(params.timeout, "timeout", 1, Number.MAX_SAFE_INTEGER);
32610
+ const ptyRows = coerceOptionalInt(params.ptyRows, "ptyRows", 1, 60);
32611
+ const ptyCols = coerceOptionalInt(params.ptyCols, "ptyCols", 1, 140);
32612
+ const effectiveBackground = params.background === true || params.pty === true;
32613
+ let spawnContext = {
32614
+ command: params.command,
32615
+ cwd: params.workdir
32616
+ };
32617
+ if (spawnHook) {
32618
+ try {
32619
+ spawnContext = await spawnHook(spawnContext);
32620
+ } catch (hookErr) {
32621
+ throw new Error(`BashSpawnHook failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
32622
+ }
32623
+ }
32624
+ let streamed = "";
32625
+ const response = await callBridge(bridge, "bash", {
32626
+ command: spawnContext.command,
32627
+ timeout,
32628
+ workdir: spawnContext.cwd ?? params.workdir,
32629
+ env: spawnContext.env,
32630
+ description: params.description,
32631
+ background: effectiveBackground,
32632
+ notify_on_completion: effectiveBackground,
32633
+ compressed: params.compressed,
32634
+ pty: params.pty,
32635
+ pty_rows: ptyRows,
32636
+ pty_cols: ptyCols
32637
+ }, extCtx, {
32638
+ transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS,
32639
+ keepBridgeOnTimeout: true,
32640
+ onProgress: ({ text }) => {
32641
+ streamed += text;
32642
+ const displayText = truncateToVisualLines(streamed, 100);
32643
+ onUpdate?.(bashResult(displayText, { streaming: true }));
32644
+ }
32645
+ }).catch((err) => {
32646
+ if (err instanceof Error && err.message.includes("permission_required")) {
32647
+ throw new Error("Permission ask reached Pi adapter — this is a bug. Pi has no permission system.");
32648
+ }
32649
+ throw err;
32650
+ });
32651
+ if (response.success === false) {
32652
+ throw new Error(response.message ?? "bash failed");
32653
+ }
32654
+ const taskId = response.task_id;
32655
+ if (response.status === "running" && taskId) {
32656
+ if (effectiveBackground) {
32657
+ trackBgTask(resolveSessionId(extCtx), taskId);
32658
+ return bashResult(formatBackgroundLaunch(taskId, params.pty === true), {
32659
+ task_id: taskId
32660
+ });
32661
+ }
32662
+ const waitTimeoutMs = timeout !== undefined ? Math.min(timeout, foregroundWaitMs) : foregroundWaitMs;
32663
+ const startedAt = Date.now();
32664
+ while (true) {
32665
+ const status = await callBridge(bridge, "bash_status", { task_id: taskId }, extCtx);
32666
+ if (status.success === false) {
32667
+ throw new Error(status.message ?? "bash_status failed");
32668
+ }
32669
+ if (isTerminalStatus(status.status)) {
32670
+ return bashResult(withBashHints(formatForegroundResult(status), params.command), {
32671
+ exit_code: status.exit_code,
32672
+ duration_ms: status.duration_ms,
32673
+ truncated: status.output_truncated,
32674
+ output_path: status.output_path,
32675
+ task_id: taskId
32676
+ });
32677
+ }
32678
+ if (Date.now() - startedAt >= waitTimeoutMs) {
32679
+ const promoted = await callBridge(bridge, "bash_promote", { task_id: taskId }, extCtx);
32680
+ if (promoted.success === false) {
32681
+ throw new Error(promoted.message ?? "bash_promote failed");
32682
+ }
32683
+ trackBgTask(resolveSessionId(extCtx), taskId);
32684
+ return bashResult(formatPromotionMessage(taskId, params.timeout, foregroundWaitMs), {
32685
+ task_id: taskId
32686
+ });
32687
+ }
32688
+ await sleep(FOREGROUND_POLL_INTERVAL_MS);
32689
+ }
32690
+ }
32691
+ const details = {
32692
+ exit_code: response.exit_code,
32693
+ duration_ms: response.duration_ms,
32694
+ truncated: response.truncated,
32695
+ output_path: response.output_path,
32696
+ task_id: taskId
32697
+ };
32698
+ const output = response.output ?? "";
32699
+ return bashResult(withBashHints(output, params.command), details);
32700
+ },
32701
+ renderCall(args, theme, context) {
32702
+ return renderBashCall(args?.command, args?.description, theme, context);
32703
+ },
32704
+ renderResult(result, _options, theme, context) {
32705
+ return renderBashResult(result, theme, context);
32733
32706
  }
32734
- throw new Error("Permission denied: external directory access was cancelled.");
32735
- } finally {
32736
- if (timer !== undefined)
32737
- clearTimeout(timer);
32707
+ });
32708
+ pi.registerTool(createBashStatusTool(ctx));
32709
+ pi.registerTool(createBashWatchTool(ctx));
32710
+ pi.registerTool(createBashWriteTool(ctx));
32711
+ pi.registerTool(createBashKillTool(ctx));
32712
+ }
32713
+ function formatBackgroundLaunch(taskId, isPty) {
32714
+ if (isPty) {
32715
+ 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.`;
32738
32716
  }
32717
+ return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
32739
32718
  }
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;
32719
+ function formatPromotionMessage(taskId, timeout, waitWindowMs) {
32720
+ const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
32721
+ 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.`;
32722
+ }
32723
+ function formatSeconds(ms) {
32724
+ return `${Number((ms / 1000).toFixed(1))}s`;
32725
+ }
32726
+ function withBashHints(output, command) {
32727
+ let result = maybeAppendConflictsHint(output);
32728
+ result = maybeAppendGrepHint(result, command);
32729
+ return result;
32730
+ }
32731
+ function formatForegroundResult(data) {
32732
+ const output = data.output_preview ?? "";
32733
+ const outputPath = data.output_path;
32734
+ const truncated = data.output_truncated === true;
32735
+ const status = data.status;
32736
+ const exit = data.exit_code;
32737
+ let rendered = output;
32738
+ if (truncated && outputPath) {
32739
+ rendered += `
32740
+ [output truncated; full output at ${outputPath}]`;
32741
+ }
32742
+ if (status === "timed_out") {
32743
+ rendered += `
32744
+ [command timed out]`;
32745
+ }
32746
+ if (typeof exit === "number" && exit !== 0) {
32747
+ rendered += `
32748
+ [exit code: ${exit}]`;
32749
+ }
32750
+ return rendered;
32751
+ }
32752
+ function sleep(ms) {
32753
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
32754
+ }
32755
+ function createBashStatusTool(ctx) {
32756
+ return {
32757
+ name: "bash_status",
32758
+ label: "bash_status",
32759
+ 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.",
32760
+ promptSnippet: "Inspect a background bash task by task_id",
32761
+ parameters: BashStatusParams,
32762
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32763
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32764
+ const data = await bashStatusSnapshot(bridge, extCtx, params.task_id, params.output_mode);
32765
+ const details = data;
32766
+ return bashStatusResult(await formatBashStatus(extCtx, params.task_id, details, params.output_mode), details);
32767
+ }
32768
+ };
32769
+ }
32770
+ function createBashWatchTool(ctx) {
32771
+ return {
32772
+ name: "bash_watch",
32773
+ label: "bash_watch",
32774
+ 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.",
32775
+ promptSnippet: "Wait for or watch a background bash task",
32776
+ parameters: BashWatchParams,
32777
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32778
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32779
+ const waitFor = parseWaitPattern(params.pattern);
32780
+ if (params.background === true) {
32781
+ if (!waitFor) {
32782
+ throw new Error("invalid_request: Use auto-reminder; bash_watch without pattern in async mode is redundant");
32797
32783
  }
32798
- const response = await callBridge(bridge, "read", req, extCtx);
32799
- if (Array.isArray(response.entries)) {
32800
- return textResult(response.entries.join(`
32801
- `));
32784
+ const notifyParams = {
32785
+ task_id: params.task_id,
32786
+ once: params.once !== false
32787
+ };
32788
+ if (waitFor.kind === "regex")
32789
+ notifyParams.regex = waitFor.source;
32790
+ else
32791
+ notifyParams.pattern = waitFor.value;
32792
+ const sessionId = resolveSessionId(extCtx);
32793
+ markExplicitControl(sessionId, params.task_id, false);
32794
+ let registered;
32795
+ try {
32796
+ registered = await callBridge(bridge, "bash_notify", notifyParams, extCtx);
32797
+ } catch (err) {
32798
+ unmarkExplicitControl(sessionId, params.task_id);
32799
+ throw err;
32802
32800
  }
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);
32801
+ if (registered.success === false) {
32802
+ unmarkExplicitControl(sessionId, params.task_id);
32803
+ const message = String(registered.message ?? "bash_notify failed");
32804
+ throw new Error(`${String(registered.code ?? "invalid_request")}: ${message}`);
32805
+ }
32806
+ markExplicitControl(sessionId, params.task_id);
32807
+ const watchDetails = { registered: true, watchId: registered.watch_id };
32808
+ return textResult(`Watch registered: ${registered.watch_id} on task ${params.task_id}
32809
+ A notification will fire when the pattern matches or the task exits.`, watchDetails);
32810
+ }
32811
+ 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));
32812
+ const text = await formatBashStatus(extCtx, params.task_id, data, undefined);
32813
+ return textResult(text, data);
32814
+ }
32815
+ };
32816
+ }
32817
+ function createBashWriteTool(ctx) {
32818
+ return {
32819
+ name: "bash_write",
32820
+ label: "bash_write",
32821
+ 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).",
32822
+ promptSnippet: "Write keystrokes/input to a PTY bash task",
32823
+ parameters: BashWriteParams,
32824
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32825
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32826
+ const data = await callBridge(bridge, "bash_write", { task_id: params.task_id, input: params.input }, extCtx);
32827
+ return textResult(JSON.stringify({ bytes_written: data.bytes_written }, null, 2), data);
32828
+ }
32829
+ };
32830
+ }
32831
+ function createBashKillTool(ctx) {
32832
+ return {
32833
+ name: "bash_kill",
32834
+ label: "bash_kill",
32835
+ description: "Terminate a running background bash task spawned with bash({ background: true }).",
32836
+ promptSnippet: "Kill a background bash task by task_id",
32837
+ parameters: BashTaskParams,
32838
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
32839
+ const bridge = bridgeFor(ctx, extCtx.cwd);
32840
+ const data = await callBridge(bridge, "bash_kill", { task_id: params.task_id }, extCtx);
32841
+ if (data.success === false) {
32842
+ throw new Error(data.message ?? "bash_kill failed");
32809
32843
  }
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);
32844
+ await disposePtyTerminal(ptyCacheKey(extCtx, params.task_id));
32845
+ const details = data;
32846
+ if (details.kill_signaled === true) {
32847
+ return bashKillResult(`Task ${params.task_id}: kill_signaled`, details);
32838
32848
  }
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);
32849
+ return bashKillResult(`Task ${params.task_id}: ${details.status}`, details);
32850
+ }
32851
+ };
32852
+ }
32853
+ function bashResult(output, details) {
32854
+ return {
32855
+ content: [{ type: "text", text: output }],
32856
+ details: {
32857
+ exit_code: details.exit_code,
32858
+ duration_ms: details.duration_ms,
32859
+ truncated: details.truncated,
32860
+ output_path: details.output_path,
32861
+ task_id: details.task_id,
32862
+ bg_completions: details.bg_completions
32863
+ }
32864
+ };
32865
+ }
32866
+ function bashStatusResult(output, details) {
32867
+ return {
32868
+ content: [{ type: "text", text: output }],
32869
+ details
32870
+ };
32871
+ }
32872
+ function bashKillResult(output, details) {
32873
+ return {
32874
+ content: [{ type: "text", text: output }],
32875
+ details
32876
+ };
32877
+ }
32878
+ async function bashStatusSnapshot(bridge, extCtx, taskId, outputMode, options) {
32879
+ return await callBridge(bridge, "bash_status", { task_id: taskId, output_mode: outputMode }, extCtx, options);
32880
+ }
32881
+ async function waitForBashStatus(ctx, bridge, extCtx, taskId, outputMode, waitFor, waitForExit, effectiveWaitMs) {
32882
+ const startedAt = Date.now();
32883
+ const deadline = startedAt + effectiveWaitMs;
32884
+ let spillCursor = { output: 0, stderr: 0, combined: 0 };
32885
+ let scanText = "";
32886
+ let scanBaseOffset = 0;
32887
+ const bridgeOptions = {
32888
+ keepBridgeOnTimeout: true,
32889
+ transportTimeoutMs: BASH_TRANSPORT_TIMEOUT_MS
32890
+ };
32891
+ const sessionId = resolveSessionId(extCtx);
32892
+ if (waitForExit)
32893
+ markTaskWaiting(sessionId, taskId);
32894
+ let sawTerminal = false;
32895
+ try {
32896
+ while (true) {
32897
+ const data = await bashStatusSnapshot(bridge, extCtx, taskId, outputMode, bridgeOptions);
32898
+ const terminal = isTerminalStatus(data.status);
32899
+ if (waitFor) {
32900
+ const scan = await readNewTaskOutput(extCtx, taskId, data, spillCursor);
32901
+ if (scan) {
32902
+ spillCursor = scan.nextCursor;
32903
+ if (scanText.length === 0)
32904
+ scanBaseOffset = scan.baseOffset;
32905
+ scanText += scan.text;
32906
+ const match = findWaitMatch(scanText, waitFor);
32907
+ if (match) {
32908
+ if (waitForExit && terminal) {
32909
+ sawTerminal = true;
32910
+ consumeBgCompletion(sessionId, taskId);
32911
+ await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32912
+ }
32913
+ return withWaited(data, {
32914
+ reason: "matched",
32915
+ elapsed_ms: Date.now() - startedAt,
32916
+ match: match.text,
32917
+ match_offset: scanBaseOffset + Buffer.byteLength(scanText.slice(0, match.index), "utf8")
32918
+ });
32919
+ }
32868
32920
  }
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
32921
  }
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);
32922
+ if (terminal) {
32923
+ if (waitForExit) {
32924
+ sawTerminal = true;
32925
+ consumeBgCompletion(sessionId, taskId);
32926
+ await markBgCompletionDelivered({ ctx, directory: extCtx.cwd, sessionID: sessionId }, taskId);
32908
32927
  }
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);
32928
+ return withWaited(data, { reason: "exited", elapsed_ms: Date.now() - startedAt });
32919
32929
  }
32920
- });
32930
+ if (Date.now() >= deadline) {
32931
+ return withWaited(data, { reason: "timeout", elapsed_ms: Date.now() - startedAt });
32932
+ }
32933
+ await sleep(Math.min(BASH_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
32934
+ }
32935
+ } finally {
32936
+ if (waitForExit && !sawTerminal)
32937
+ unmarkTaskWaiting(sessionId, taskId);
32938
+ if (waitFor) {
32939
+ await disposePtyTerminal(watchPtyCacheKey(extCtx, taskId));
32940
+ }
32921
32941
  }
32922
32942
  }
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;
32940
- }
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.`;
32947
- }
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)}`;
32943
+ async function readNewTaskOutput(extCtx, taskId, data, cursor) {
32944
+ const outputPath = data.output_path;
32945
+ if (data.mode === "pty") {
32946
+ if (!outputPath)
32947
+ return;
32948
+ const { rows, cols } = ptyDimensions(data);
32949
+ const state = await getOrCreatePtyTerminal(watchPtyCacheKey(extCtx, taskId), outputPath, rows, cols);
32950
+ const baseOffset = state.offset;
32951
+ const bytes = await readPtyBytes(state);
32952
+ if (bytes.length === 0)
32953
+ return;
32954
+ return {
32955
+ text: bytes.toString("utf8"),
32956
+ baseOffset,
32957
+ nextCursor: { output: state.offset, stderr: 0, combined: state.offset }
32958
+ };
32963
32959
  }
32960
+ const stderrPath = data.stderr_path;
32961
+ if (!outputPath && !stderrPath)
32962
+ return;
32963
+ const stdoutBytes = outputPath ? await readFileBytesFrom(outputPath, cursor.output) : Buffer.alloc(0);
32964
+ const stderrBytes = stderrPath ? await readFileBytesFrom(stderrPath, cursor.stderr) : Buffer.alloc(0);
32965
+ const bytesRead = stdoutBytes.length + stderrBytes.length;
32966
+ if (bytesRead === 0)
32967
+ return;
32964
32968
  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
32969
+ text: Buffer.concat([stdoutBytes, stderrBytes]).toString("utf8"),
32970
+ baseOffset: cursor.combined,
32971
+ nextCursor: {
32972
+ output: cursor.output + stdoutBytes.length,
32973
+ stderr: cursor.stderr + stderrBytes.length,
32974
+ combined: cursor.combined + bytesRead
32977
32975
  }
32978
32976
  };
32979
32977
  }
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.`;
32987
- }
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:
32978
+ async function readFileBytesFrom(outputPath, cursor) {
32979
+ const handle = await fs3.open(outputPath, "r");
32980
+ try {
32981
+ const chunks = [];
32982
+ let offset = cursor;
32983
+ while (true) {
32984
+ const buffer2 = Buffer.allocUnsafe(64 * 1024);
32985
+ const { bytesRead } = await handle.read(buffer2, 0, buffer2.length, offset);
32986
+ if (bytesRead === 0)
32987
+ break;
32988
+ chunks.push(Buffer.from(buffer2.subarray(0, bytesRead)));
32989
+ offset += bytesRead;
32990
+ }
32991
+ return Buffer.concat(chunks);
32992
+ } finally {
32993
+ await handle.close().catch(() => {
32999
32994
  return;
32995
+ });
33000
32996
  }
33001
32997
  }
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(`
33014
- `);
33015
- } catch {
33016
- return JSON.stringify(diagnostics, null, 2);
32998
+ function parseWaitPattern(value) {
32999
+ if (typeof value === "string")
33000
+ return { kind: "substring", value };
33001
+ if (isRegexWaitObject(value))
33002
+ return { kind: "regex", value: new RegExp(value.regex), source: value.regex };
33003
+ return;
33004
+ }
33005
+ function isRegexWaitObject(value) {
33006
+ return typeof value === "object" && value !== null && "regex" in value && typeof value.regex === "string";
33007
+ }
33008
+ function findWaitMatch(text, pattern) {
33009
+ if (pattern.kind === "substring") {
33010
+ const index = text.indexOf(pattern.value);
33011
+ return index >= 0 ? { text: pattern.value, index } : undefined;
33017
33012
  }
33013
+ pattern.value.lastIndex = 0;
33014
+ const match = pattern.value.exec(text);
33015
+ return match ? { text: match[0], index: match.index } : undefined;
33018
33016
  }
33019
- function reuseText3(last) {
33020
- return last instanceof Text3 ? last : new Text3("", 0, 0);
33017
+ function withWaited(data, waited) {
33018
+ return { ...data, waited };
33021
33019
  }
33022
- function reuseContainer3(last) {
33023
- return last instanceof Container3 ? last : new Container3;
33020
+ function formatWaitSummary(waited, details) {
33021
+ if (waited.reason === "matched") {
33022
+ return `Waited ${waited.elapsed_ms}ms; matched ${JSON.stringify(waited.match ?? "")} at offset ${waited.match_offset ?? 0}.`;
33023
+ }
33024
+ if (waited.reason === "timeout") {
33025
+ return `Waited ${waited.elapsed_ms}ms; timeout reached without match.`;
33026
+ }
33027
+ const exit = typeof details.exit_code === "number" ? `, exit ${details.exit_code}` : "";
33028
+ return `Waited ${waited.elapsed_ms}ms; task exited (${details.status}${exit}).`;
33024
33029
  }
33025
- function renderMutationCall(toolName, filePath, theme, context) {
33030
+ async function formatBashStatus(extCtx, taskId, details, requestedOutputMode) {
33031
+ const exit = typeof details.exit_code === "number" ? ` (exit ${details.exit_code})` : "";
33032
+ const dur = typeof details.duration_ms === "number" ? ` ${Math.round(details.duration_ms / 1000)}s` : "";
33033
+ let text = `Task ${taskId}: ${details.status}${exit}${dur}`;
33034
+ if (details.waited)
33035
+ text += `
33036
+ ${formatWaitSummary(details.waited, details)}`;
33037
+ if (details.mode === "pty") {
33038
+ text += await formatPtyStatus(extCtx, taskId, details, requestedOutputMode);
33039
+ } else {
33040
+ if (isTerminalStatus(details.status) && details.output_preview) {
33041
+ text += `
33042
+ ${details.output_preview.slice(0, 2000)}`;
33043
+ }
33044
+ if (!isTerminalStatus(details.status)) {
33045
+ text += `
33046
+ A completion reminder will be delivered automatically; don't poll.`;
33047
+ }
33048
+ }
33049
+ return text;
33050
+ }
33051
+ async function formatPtyStatus(extCtx, taskId, details, requestedOutputMode) {
33052
+ if (!details.output_path)
33053
+ return `
33054
+ [PTY output path unavailable]`;
33055
+ const key = ptyCacheKey(extCtx, taskId);
33056
+ const { rows, cols } = ptyDimensions(details);
33057
+ const state = await getOrCreatePtyTerminal(key, details.output_path, rows, cols);
33058
+ const raw = await readPtyBytes(state);
33059
+ const outputMode = requestedOutputMode ?? "screen";
33060
+ let suffix = "";
33061
+ if (outputMode === "raw") {
33062
+ suffix = raw.length > 0 ? `
33063
+ ${raw.toString("utf8")}` : "";
33064
+ } else if (outputMode === "both") {
33065
+ suffix = `
33066
+ ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
33067
+ } else {
33068
+ const screen = renderScreen(state, rows, cols);
33069
+ suffix = screen ? `
33070
+ ${screen}` : "";
33071
+ }
33072
+ if (!isTerminalStatus(details.status)) {
33073
+ suffix += `
33074
+ 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.`;
33075
+ } else {
33076
+ await disposePtyTerminal(key);
33077
+ }
33078
+ return suffix;
33079
+ }
33080
+ function ptyDimensions(data) {
33081
+ const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
33082
+ const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
33083
+ return { rows, cols };
33084
+ }
33085
+ function ptyCacheKey(extCtx, taskId) {
33086
+ return `${extCtx.cwd}::${resolveSessionId(extCtx) ?? "__default__"}::${taskId}`;
33087
+ }
33088
+ function watchPtyCacheKey(extCtx, taskId) {
33089
+ return `${ptyCacheKey(extCtx, taskId)}::watch`;
33090
+ }
33091
+ function isTerminalStatus(status) {
33092
+ return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
33093
+ }
33094
+ function renderBashCall(command, description, theme, context) {
33026
33095
  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}`);
33096
+ const display = description ?? (command ? shortenCommand(command) : "...");
33097
+ text.setText(`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", display)}`);
33029
33098
  return text;
33030
33099
  }
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)")}`;
33100
+ function renderBashResult(result, theme, context) {
33101
+ if (context.isError) {
33102
+ const errorText = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
33103
+ `).trim();
33104
+ const text = reuseText3(context.lastComponent);
33105
+ text.setText(`
33106
+ ${theme.fg("error", errorText || "bash failed")}`);
33107
+ return text;
33108
+ }
33109
+ const details = result.details;
33110
+ const exitCode = details?.exit_code;
33111
+ const bgCompletions = details?.bg_completions ?? [];
33112
+ const container = reuseContainer3(context.lastComponent);
33113
+ container.clear();
33114
+ container.addChild(new Spacer3(1));
33115
+ const rawOutput = result.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(`
33116
+ `).trim();
33117
+ if (rawOutput) {
33118
+ const lines = rawOutput.split(`
33119
+ `);
33120
+ const preview = lines.length > 25 ? `... (${lines.length - 25} lines omitted)
33121
+ ${lines.slice(-25).join(`
33122
+ `)}` : rawOutput;
33123
+ container.addChild(new Text3(preview, 1, 0));
33124
+ container.addChild(new Spacer3(1));
33125
+ }
33126
+ if (exitCode !== undefined) {
33127
+ const exitColor = exitCode === 0 ? "success" : "error";
33128
+ const exitText = theme.fg(exitColor, `exit ${exitCode}`);
33129
+ container.addChild(new Text3(exitText, 1, 0));
33130
+ }
33131
+ if (bgCompletions.length > 0) {
33132
+ container.addChild(new Spacer3(1));
33133
+ for (const bg of bgCompletions) {
33134
+ const cmdPreview = bg.command ? bg.command.slice(0, 60) : "unknown command";
33135
+ const suffix = (bg.command?.length ?? 0) > 60 ? "..." : "";
33136
+ const exitInfo = bg.exit_code !== undefined ? `exit ${bg.exit_code}` : bg.status;
33137
+ const statusColor = bg.status === "completed" && bg.exit_code === 0 ? "success" : "warning";
33138
+ const line = theme.fg(statusColor, `Background task ${bg.task_id} completed (${exitInfo}): ${cmdPreview}${suffix}`);
33139
+ container.addChild(new Text3(line, 1, 0));
33140
+ }
33141
+ }
33142
+ if (details?.duration_ms !== undefined) {
33143
+ container.addChild(new Spacer3(1));
33144
+ const durationText = theme.fg("muted", `${details.duration_ms}ms`);
33145
+ container.addChild(new Text3(durationText, 1, 0));
33146
+ }
33147
+ if (details?.truncated) {
33148
+ container.addChild(new Spacer3(1));
33149
+ const truncText = theme.fg("warning", "(output truncated)");
33150
+ container.addChild(new Text3(truncText, 1, 0));
33151
+ }
33152
+ return container;
33153
+ }
33154
+ function shortenCommand(command) {
33155
+ if (command.length <= 60)
33156
+ return command;
33157
+ return `${command.slice(0, 57)}...`;
33158
+ }
33159
+
33160
+ // src/tools/conflicts.ts
33161
+ import { Type as Type5 } from "typebox";
33162
+ var ConflictsParams = Type5.Object({
33163
+ path: Type5.Optional(Type5.String({
33164
+ 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."
33165
+ }))
33166
+ });
33167
+ function renderConflictCall(theme, context) {
33168
+ return renderToolCall("conflicts", undefined, theme, context);
33169
+ }
33170
+ function buildConflictSections(text) {
33171
+ const trimmed = text.trim();
33172
+ if (!trimmed)
33173
+ return ["No merge conflicts found."];
33174
+ const [header, ...rest] = trimmed.split(/\n\n+/);
33175
+ const match = header.match(/^(\d+)\s+files?,\s+(\d+)\s+conflicts?/i);
33176
+ const sections = [
33177
+ match ? `${match[1]} conflicted file${match[1] === "1" ? "" : "s"} · ${match[2]} region${match[2] === "1" ? "" : "s"}` : header
33178
+ ];
33179
+ if (rest.length === 0)
33180
+ return sections;
33181
+ sections.push(...rest.map((section) => section.trim()).filter(Boolean));
33182
+ return sections;
33183
+ }
33184
+ function renderConflictResult(text, theme, context) {
33185
+ const sections = buildConflictSections(text).map((section, index) => index === 0 ? theme.fg("warning", section) : section);
33186
+ return renderSections(sections, context);
33187
+ }
33188
+ function renderConflictToolResult(result, theme, context) {
33189
+ if (context.isError)
33190
+ return renderErrorResult(result, "conflicts failed", theme, context);
33191
+ return renderConflictResult(collectTextContent(result), theme, context);
33192
+ }
33193
+ function registerConflictsTool(pi, ctx) {
33194
+ pi.registerTool({
33195
+ name: "aft_conflicts",
33196
+ label: "conflicts",
33197
+ description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call.",
33198
+ parameters: ConflictsParams,
33199
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33200
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33201
+ const reqParams = {};
33202
+ const path2 = params?.path;
33203
+ if (typeof path2 === "string" && path2.trim() !== "")
33204
+ reqParams.path = path2;
33205
+ const response = await callBridge(bridge, "git_conflicts", reqParams, extCtx);
33206
+ return textResult(response.text ?? JSON.stringify(response, null, 2));
33207
+ },
33208
+ renderCall(_args, theme, context) {
33209
+ return renderConflictCall(theme, context);
33210
+ },
33211
+ renderResult(result, _options, theme, context) {
33212
+ return renderConflictToolResult(result, theme, context);
33052
33213
  }
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;
33214
+ });
33068
33215
  }
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;
33216
+
33217
+ // src/tools/fs.ts
33218
+ import { Type as Type6 } from "typebox";
33219
+ var DeleteParams = Type6.Object({
33220
+ files: Type6.Array(Type6.String(), {
33221
+ description: "Paths to delete (one or more). May include directories when recursive=true.",
33222
+ minItems: 1
33223
+ }),
33224
+ recursive: Type6.Optional(Type6.Boolean({
33225
+ description: "Required to delete a directory and its contents. Defaults to false; passing a directory without this returns an error."
33226
+ }))
33227
+ });
33228
+ var MoveParams = Type6.Object({
33229
+ filePath: Type6.String({
33230
+ description: "Source file path to move (absolute or relative to project root)"
33231
+ }),
33232
+ destination: Type6.String({
33233
+ description: "Destination file path (absolute or relative to project root)"
33234
+ })
33235
+ });
33236
+ function renderFsCall(toolName, args, theme, context) {
33237
+ if (toolName === "aft_delete") {
33238
+ const files = args.files;
33239
+ const summary = files.length === 1 ? accentPath(theme, files[0]) : `${theme.fg("accent", String(files.length))} ${theme.fg("muted", "files")}`;
33240
+ return renderToolCall("delete", summary, theme, context);
33077
33241
  }
33242
+ const moveArgs = args;
33243
+ return renderToolCall("move", `${accentPath(theme, moveArgs.filePath)} ${theme.fg("muted", "→")} ${accentPath(theme, moveArgs.destination)}`, theme, context);
33078
33244
  }
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;
33245
+ function renderFsResult(toolName, args, result, theme, context) {
33246
+ if (context.isError) {
33247
+ return renderErrorResult(result, `${toolName} failed`, theme, context);
33248
+ }
33249
+ if (toolName === "aft_delete") {
33250
+ const files = args.files;
33251
+ const data = result?.details ?? {};
33252
+ const deletedPaths = data.deleted ?? files;
33253
+ const skipped = data.skipped_files ?? [];
33254
+ const lines = [];
33255
+ for (const entry of deletedPaths) {
33256
+ lines.push(`${theme.fg("success", "✓ deleted")} ${theme.fg("accent", shortenPath2(entry))}`);
33088
33257
  }
33089
- if (ch === "}") {
33090
- if (depth > 0)
33091
- depth--;
33092
- buf += ch;
33093
- continue;
33258
+ for (const entry of skipped) {
33259
+ lines.push(`${theme.fg("error", "✗ skipped")} ${theme.fg("accent", shortenPath2(entry.file))} ${theme.fg("muted", `(${entry.reason})`)}`);
33094
33260
  }
33095
- if (ch === "," && depth === 0) {
33096
- const trimmed = buf.trim();
33097
- if (trimmed.length > 0)
33098
- out.push(trimmed);
33099
- buf = "";
33100
- continue;
33261
+ if (lines.length === 0) {
33262
+ lines.push(theme.fg("muted", "(no files deleted)"));
33101
33263
  }
33102
- buf += ch;
33264
+ return renderSections([lines.join(`
33265
+ `)], context);
33103
33266
  }
33104
- const tail2 = buf.trim();
33105
- if (tail2.length > 0)
33106
- out.push(tail2);
33107
- return out;
33267
+ const moveArgs = args;
33268
+ return renderSections([
33269
+ `${theme.fg("success", "✓ moved")} ${theme.fg("accent", shortenPath2(moveArgs.filePath))}`,
33270
+ `${theme.fg("muted", "to")} ${theme.fg("accent", shortenPath2(moveArgs.destination))}`
33271
+ ], context);
33108
33272
  }
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 "";
33273
+ function registerFsTools(pi, ctx, surface) {
33274
+ if (surface.delete) {
33275
+ pi.registerTool({
33276
+ name: "aft_delete",
33277
+ label: "delete",
33278
+ 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.",
33279
+ parameters: DeleteParams,
33280
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33281
+ const files = await Promise.all(params.files.map((file2) => resolvePathArg(extCtx.cwd, file2)));
33282
+ const checked = new Set;
33283
+ for (const file2 of files) {
33284
+ if (checked.has(file2))
33285
+ continue;
33286
+ checked.add(file2);
33287
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
33288
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33289
+ });
33290
+ }
33291
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33292
+ const response = await callBridge(bridge, "delete_file", {
33293
+ files,
33294
+ recursive: params.recursive === true
33295
+ }, extCtx);
33296
+ const deletedEntries = response.deleted ?? [];
33297
+ const skipped = response.skipped_files ?? [];
33298
+ const deleted = deletedEntries.map((entry) => entry.file);
33299
+ if (deleted.length === 0 && skipped.length > 0) {
33300
+ throw new Error(`delete failed for all ${skipped.length} file(s):
33301
+ ` + skipped.map((entry) => ` ${entry.file}: ${entry.reason}`).join(`
33302
+ `));
33303
+ }
33304
+ const summary = deleted.length === 1 && skipped.length === 0 ? `Deleted ${deleted[0]}` : `Deleted ${deleted.length}/${params.files.length} file(s)`;
33305
+ return textResult(summary, {
33306
+ success: true,
33307
+ complete: skipped.length === 0,
33308
+ deleted,
33309
+ skipped_files: skipped
33310
+ });
33311
+ },
33312
+ renderCall(args, theme, context) {
33313
+ return renderFsCall("aft_delete", args, theme, context);
33314
+ },
33315
+ renderResult(result, _options, theme, context) {
33316
+ return renderFsResult("aft_delete", context.args, result, theme, context);
33317
+ }
33318
+ });
33319
+ }
33320
+ if (surface.move) {
33321
+ pi.registerTool({
33322
+ name: "aft_move",
33323
+ label: "move",
33324
+ 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.",
33325
+ parameters: MoveParams,
33326
+ async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33327
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33328
+ const destination = await resolvePathArg(extCtx.cwd, params.destination);
33329
+ const checked = new Set([filePath, destination]);
33330
+ for (const file2 of checked) {
33331
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
33332
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33333
+ });
33334
+ }
33335
+ const bridge = bridgeFor(ctx, extCtx.cwd);
33336
+ const response = await callBridge(bridge, "move_file", {
33337
+ file: filePath,
33338
+ destination
33339
+ }, extCtx);
33340
+ return textResult(`Moved ${params.filePath} → ${params.destination}`, response);
33341
+ },
33342
+ renderCall(args, theme, context) {
33343
+ return renderFsCall("aft_move", args, theme, context);
33344
+ },
33345
+ renderResult(result, _options, theme, context) {
33346
+ return renderFsResult("aft_move", context.args, result, theme, context);
33347
+ }
33348
+ });
33119
33349
  }
33120
- return `
33121
- (Showing lines ${startLine}-${endLine} of ${totalLines}. Use offset/limit to read other sections.)`;
33122
33350
  }
33123
33351
 
33124
33352
  // src/tools/imports.ts
@@ -33128,8 +33356,20 @@ var ImportParams = Type7.Object({
33128
33356
  op: StringEnum2(["add", "remove", "organize"], { description: "Import operation" }),
33129
33357
  filePath: Type7.String({ description: "Path to the file (absolute or relative to project root)" }),
33130
33358
  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')" })),
33359
+ names: Type7.Optional(Type7.Array(Type7.String(), {
33360
+ 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']"
33361
+ })),
33362
+ defaultImport: Type7.Optional(Type7.String({ description: "Default import name, ES only (e.g. 'React')" })),
33363
+ namespace: Type7.Optional(Type7.String({
33364
+ description: "Namespace binding: `import * as ns from 'mod'` (ES), `* as N from \"./X.sol\"` (Solidity)"
33365
+ })),
33366
+ alias: Type7.Optional(Type7.String({ description: 'Whole-module alias. Solidity: `import "./X.sol" as X`' })),
33367
+ modifiers: Type7.Optional(Type7.Array(Type7.String(), {
33368
+ description: "Statement-level modifiers, language-validated: Java/C# 'static', C# 'global'/'unsafe', Java/Kotlin/Scala 'wildcard', Swift '@testable'"
33369
+ })),
33370
+ importKind: Type7.Optional(Type7.String({
33371
+ description: "Symbol-kind import: PHP 'function'/'const', Swift 'struct'/'class'/'enum', Scala 'given'"
33372
+ })),
33133
33373
  removeName: Type7.Optional(Type7.String({ description: "Named import to remove; omit to remove entire import" })),
33134
33374
  typeOnly: Type7.Optional(Type7.Boolean({ description: "Type-only import (TS only)" })),
33135
33375
  validate: Type7.Optional(StringEnum2(["syntax", "full"], {
@@ -33150,16 +33390,19 @@ function buildImportSections(args, payload, theme) {
33150
33390
  ];
33151
33391
  }
33152
33392
  if (args.op === "add") {
33153
- const moduleName = asString(response.module) ?? args.module ?? "(module)";
33393
+ const moduleName2 = asString(response.module) ?? args.module ?? "(module)";
33154
33394
  const status = response.already_present === true ? theme.fg("warning", "already present") : theme.fg("success", "added");
33155
33395
  return [
33156
- `${status} ${theme.fg("accent", moduleName)}`,
33396
+ `${status} ${theme.fg("accent", moduleName2)}`,
33157
33397
  `${theme.fg("muted", "file")} ${theme.fg("accent", asString(response.file) ?? args.filePath)}`,
33158
33398
  `${theme.fg("muted", "group")} ${asString(response.group) ?? "—"}`
33159
33399
  ];
33160
33400
  }
33401
+ const moduleName = asString(response.module) ?? args.module ?? "(module)";
33402
+ const didRemove = response.removed !== false;
33403
+ const removeStatus = didRemove ? `${theme.fg("success", "removed")} ${theme.fg("accent", moduleName)}` : `${theme.fg("warning", "not present")} ${theme.fg("accent", moduleName)}`;
33161
33404
  return [
33162
- `${theme.fg("success", "removed")} ${theme.fg("accent", asString(response.module) ?? args.module ?? "(module)")}`,
33405
+ removeStatus,
33163
33406
  `${theme.fg("muted", "file")} ${theme.fg("accent", asString(response.file) ?? args.filePath)}`,
33164
33407
  args.removeName ? `${theme.fg("muted", "name")} ${args.removeName}` : `${theme.fg("muted", "scope")} entire import`
33165
33408
  ];
@@ -33182,25 +33425,37 @@ function registerImportTools(pi, ctx) {
33182
33425
  pi.registerTool({
33183
33426
  name: "aft_import",
33184
33427
  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.",
33428
+ 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
33429
  parameters: ImportParams,
33187
33430
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33188
- if ((params.op === "add" || params.op === "remove") && !params.module) {
33431
+ if ((params.op === "add" || params.op === "remove") && isEmptyParam(params.module)) {
33189
33432
  throw new Error(`op='${params.op}' requires 'module'`);
33190
33433
  }
33434
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33435
+ await assertExternalDirectoryPermission(extCtx, filePath, "modify", {
33436
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33437
+ });
33191
33438
  const bridge = bridgeFor(ctx, extCtx.cwd);
33192
33439
  const commandMap = {
33193
33440
  add: "add_import",
33194
33441
  remove: "remove_import",
33195
33442
  organize: "organize_imports"
33196
33443
  };
33197
- const req = { file: params.filePath };
33444
+ const req = { file: filePath };
33198
33445
  if (params.module !== undefined)
33199
33446
  req.module = params.module;
33200
33447
  if (params.names !== undefined)
33201
33448
  req.names = params.names;
33202
33449
  if (params.defaultImport !== undefined)
33203
33450
  req.default_import = params.defaultImport;
33451
+ if (params.namespace !== undefined)
33452
+ req.namespace = params.namespace;
33453
+ if (params.alias !== undefined)
33454
+ req.alias = params.alias;
33455
+ if (params.modifiers !== undefined)
33456
+ req.modifiers = params.modifiers;
33457
+ if (params.importKind !== undefined)
33458
+ req.import_kind = params.importKind;
33204
33459
  if (params.removeName !== undefined)
33205
33460
  req.name = params.removeName;
33206
33461
  if (params.typeOnly !== undefined)
@@ -33243,6 +33498,22 @@ var lastTier2TriggerAtByBridge = new WeakMap;
33243
33498
  function normalizeStringOrArray(value) {
33244
33499
  return isEmptyParam(value) ? undefined : value;
33245
33500
  }
33501
+ async function resolveAndGateScope(extCtx, ctx, scope) {
33502
+ if (scope === undefined)
33503
+ return;
33504
+ const values = Array.isArray(scope) ? scope : [scope];
33505
+ const resolved = await Promise.all(values.filter((value) => typeof value === "string" && value.length > 0).map((value) => resolvePathArg(extCtx.cwd, value)));
33506
+ const checked = new Set;
33507
+ for (const target of resolved) {
33508
+ if (checked.has(target))
33509
+ continue;
33510
+ checked.add(target);
33511
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
33512
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33513
+ });
33514
+ }
33515
+ return Array.isArray(scope) ? resolved : resolved[0];
33516
+ }
33246
33517
  function validateOptionalTopK(value) {
33247
33518
  if (value === undefined || value === null || value === "")
33248
33519
  return;
@@ -33323,6 +33594,45 @@ function tier2SummaryPart(summary, key, label) {
33323
33594
  const status = asString(section?.status);
33324
33595
  return `${label} ${status ?? "unavailable"}`;
33325
33596
  }
33597
+ function shortDupOccurrence(entry) {
33598
+ const [path2] = entry.split(":");
33599
+ return path2?.split("/").pop() ?? entry;
33600
+ }
33601
+ function tier2TopPreview(summary, theme) {
33602
+ const lines = [];
33603
+ const dup = asRecord2(summary?.duplicates);
33604
+ const dupTop = Array.isArray(dup?.top) ? dup.top : [];
33605
+ for (const group of dupTop) {
33606
+ const record2 = asRecord2(group);
33607
+ const files = Array.isArray(record2?.files) ? record2.files : [];
33608
+ const cost = asNumber(record2?.cost);
33609
+ if (files.length < 2)
33610
+ continue;
33611
+ const a = shortDupOccurrence(String(files[0]));
33612
+ const b = shortDupOccurrence(String(files[1]));
33613
+ lines.push(` dup ${a} ↔ ${b}${cost !== undefined ? ` (${cost})` : ""}`);
33614
+ }
33615
+ for (const [key, label] of [
33616
+ ["dead_code", "dead"],
33617
+ ["unused_exports", "unused"]
33618
+ ]) {
33619
+ const section = asRecord2(summary?.[key]);
33620
+ const top = Array.isArray(section?.top) ? section.top : [];
33621
+ for (const item of top) {
33622
+ const record2 = asRecord2(item);
33623
+ const file2 = asString(record2?.file);
33624
+ const symbol2 = asString(record2?.symbol);
33625
+ if (!file2 || !symbol2)
33626
+ continue;
33627
+ lines.push(` ${label} ${symbol2} (${file2.split("/").pop()})`);
33628
+ }
33629
+ }
33630
+ if (lines.length === 0)
33631
+ return;
33632
+ return `${theme.fg("muted", "top findings:")}
33633
+ ${lines.join(`
33634
+ `)}`;
33635
+ }
33326
33636
  function tier2RefreshCategories(response) {
33327
33637
  const scannerState = asRecord2(response.scanner_state);
33328
33638
  const categories = new Set;
@@ -33391,6 +33701,9 @@ function buildInspectSections(payload, theme) {
33391
33701
  if (stale > 0 || pending > 0) {
33392
33702
  sections.push(theme.fg("warning", `scanner state: ${stale} stale · ${pending} pending`));
33393
33703
  }
33704
+ const topPreview = tier2TopPreview(summary, theme);
33705
+ if (topPreview)
33706
+ sections.push(topPreview);
33394
33707
  const details = asRecord2(response.details);
33395
33708
  if (details) {
33396
33709
  const names = Object.keys(details);
@@ -33426,7 +33739,7 @@ function registerInspectTool(pi, ctx) {
33426
33739
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33427
33740
  const bridge = bridgeFor(ctx, extCtx.cwd);
33428
33741
  const sections = normalizeStringOrArray(params.sections);
33429
- const scope = normalizeStringOrArray(params.scope);
33742
+ const scope = await resolveAndGateScope(extCtx, ctx, normalizeStringOrArray(params.scope));
33430
33743
  const topK = validateOptionalTopK(params.topK);
33431
33744
  const response = await callBridge(bridge, "inspect", { sections, scope, topK }, extCtx);
33432
33745
  runPendingTier2Categories(bridge, tier2RefreshCategories(response), extCtx);
@@ -33468,7 +33781,7 @@ function treeLine(depth, text) {
33468
33781
  }
33469
33782
  function renderCallTreeNode(node, depth, lines) {
33470
33783
  const name = asString(node.name) ?? "(unknown)";
33471
- const file2 = shortenPath(asString(node.file) ?? "(unknown file)");
33784
+ const file2 = shortenPath2(asString(node.file) ?? "(unknown file)");
33472
33785
  const line = asNumber(node.line);
33473
33786
  lines.push(treeLine(depth, `${name} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
33474
33787
  asRecords(node.children).forEach((child) => {
@@ -33487,7 +33800,7 @@ function renderTracePath(path2, index, lines) {
33487
33800
  lines.push(`Path ${index + 1}`);
33488
33801
  asRecords(path2.hops).forEach((hop, hopIndex) => {
33489
33802
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33490
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33803
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33491
33804
  const line = asNumber(hop.line);
33492
33805
  const entry = hop.is_entry_point === true ? " [entry]" : "";
33493
33806
  lines.push(treeLine(hopIndex + 1, `${symbol2}${entry} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
@@ -33512,7 +33825,7 @@ function buildNavigateSections(args, payload, theme) {
33512
33825
  `${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
33826
  ];
33514
33827
  groups.forEach((group) => {
33515
- const file2 = shortenPath(asString(group.file) ?? "(unknown file)");
33828
+ const file2 = shortenPath2(asString(group.file) ?? "(unknown file)");
33516
33829
  const lines = [theme.fg("accent", file2)];
33517
33830
  asRecords(group.callers).forEach((caller) => {
33518
33831
  lines.push(` ↳ ${asString(caller.symbol) ?? "(unknown)"} ${theme.fg("muted", `line ${asNumber(caller.line) ?? "?"}`)}`);
@@ -33533,7 +33846,7 @@ function buildNavigateSections(args, payload, theme) {
33533
33846
  const lines = [theme.fg("success", `${path2.length} hop${path2.length === 1 ? "" : "s"}`)];
33534
33847
  path2.forEach((hop, index) => {
33535
33848
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33536
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33849
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33537
33850
  const line = asNumber(hop.line);
33538
33851
  lines.push(treeLine(index + 1, `${symbol2} ${line !== undefined ? `[${file2}:${line}]` : `[${file2}]`}`));
33539
33852
  });
@@ -33564,7 +33877,7 @@ function buildNavigateSections(args, payload, theme) {
33564
33877
  if (callers.length === 0)
33565
33878
  sections2.push(theme.fg("muted", "No impacted callers found."));
33566
33879
  callers.forEach((caller) => {
33567
- const file2 = shortenPath(asString(caller.caller_file) ?? "(unknown file)");
33880
+ const file2 = shortenPath2(asString(caller.caller_file) ?? "(unknown file)");
33568
33881
  const symbol2 = asString(caller.caller_symbol) ?? "(unknown)";
33569
33882
  const line = asNumber(caller.line) ?? 0;
33570
33883
  const entry = caller.is_entry_point === true ? ` ${theme.fg("warning", "[entry]")}` : "";
@@ -33587,7 +33900,7 @@ function buildNavigateSections(args, payload, theme) {
33587
33900
  if (hops.length === 0)
33588
33901
  sections.push(theme.fg("muted", "No data-flow hops found."));
33589
33902
  hops.forEach((hop, index) => {
33590
- const file2 = shortenPath(asString(hop.file) ?? "(unknown file)");
33903
+ const file2 = shortenPath2(asString(hop.file) ?? "(unknown file)");
33591
33904
  const symbol2 = asString(hop.symbol) ?? "(unknown)";
33592
33905
  const variable = asString(hop.variable) ?? "(unknown)";
33593
33906
  const line = asNumber(hop.line) ?? 0;
@@ -33617,27 +33930,44 @@ function registerNavigateTool(pi, ctx) {
33617
33930
  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
33931
  parameters: navigateParamsSchema(),
33619
33932
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33620
- if (params.op === "trace_data" && !params.expression) {
33933
+ if (isEmptyParam(params.filePath)) {
33934
+ throw new Error(`op='${params.op}' requires a \`filePath\``);
33935
+ }
33936
+ if (isEmptyParam(params.symbol)) {
33937
+ throw new Error(`op='${params.op}' requires a \`symbol\``);
33938
+ }
33939
+ if (params.op === "trace_data" && isEmptyParam(params.expression)) {
33621
33940
  throw new Error("op='trace_data' requires an `expression`");
33622
33941
  }
33623
- if (params.op === "trace_to_symbol" && !params.toSymbol) {
33942
+ if (params.op === "trace_to_symbol" && isEmptyParam(params.toSymbol)) {
33624
33943
  throw new Error("op='trace_to_symbol' requires a `toSymbol`");
33625
33944
  }
33945
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
33946
+ const toFile = !isEmptyParam(params.toFile) ? await resolvePathArg(extCtx.cwd, params.toFile) : undefined;
33947
+ const checked = new Set;
33948
+ for (const target of [filePath, ...toFile !== undefined ? [toFile] : []]) {
33949
+ if (checked.has(target))
33950
+ continue;
33951
+ checked.add(target);
33952
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
33953
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
33954
+ });
33955
+ }
33626
33956
  const bridge = bridgeFor(ctx, extCtx.cwd);
33627
33957
  const req = {
33628
33958
  op: params.op,
33629
- file: params.filePath,
33959
+ file: filePath,
33630
33960
  symbol: params.symbol
33631
33961
  };
33632
33962
  const depth = coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER);
33633
33963
  if (depth !== undefined)
33634
33964
  req.depth = depth;
33635
- if (params.expression !== undefined)
33965
+ if (!isEmptyParam(params.expression))
33636
33966
  req.expression = params.expression;
33637
- if (params.toSymbol !== undefined)
33967
+ if (!isEmptyParam(params.toSymbol))
33638
33968
  req.toSymbol = params.toSymbol;
33639
- if (params.toFile !== undefined)
33640
- req.toFile = params.toFile;
33969
+ if (toFile !== undefined)
33970
+ req.toFile = toFile;
33641
33971
  const response = await callBridge(bridge, params.op, req, extCtx);
33642
33972
  return textResult(JSON.stringify(response, null, 2));
33643
33973
  },
@@ -33652,7 +33982,6 @@ function registerNavigateTool(pi, ctx) {
33652
33982
 
33653
33983
  // src/tools/reading.ts
33654
33984
  import { stat as stat2 } from "node:fs/promises";
33655
- import { resolve as resolve4 } from "node:path";
33656
33985
  import { Type as Type10 } from "typebox";
33657
33986
  var OutlineParams = Type10.Object({
33658
33987
  target: Type10.Union([Type10.String(), Type10.Array(Type10.String())], {
@@ -33677,11 +34006,26 @@ var ZoomParams = Type10.Object({
33677
34006
  targets: Type10.Optional(Type10.Union([ZoomTarget, Type10.Array(ZoomTarget)], {
33678
34007
  description: "Cross-file batch: `{ filePath, symbol }` or an array of them. Mutually exclusive with filePath/url/symbols."
33679
34008
  })),
33680
- contextLines: Type10.Optional(Type10.Number({ description: "Lines of context before/after (default: 3)" }))
34009
+ contextLines: Type10.Optional(Type10.Number({ description: "Lines of context before/after (default: 3)" })),
34010
+ callgraph: Type10.Optional(Type10.Boolean({
34011
+ description: "Include call-graph annotations (calls-out / called-by within the same file). Default false; off keeps zoom output minimal."
34012
+ }))
33681
34013
  });
33682
34014
  function isUrl(s) {
33683
34015
  return s.startsWith("http://") || s.startsWith("https://");
33684
34016
  }
34017
+ async function assertReadPathPermissions(extCtx, ctx, paths) {
34018
+ const targets = Array.isArray(paths) ? paths : [paths];
34019
+ const checked = new Set;
34020
+ for (const target of targets) {
34021
+ if (!target || checked.has(target))
34022
+ continue;
34023
+ checked.add(target);
34024
+ await assertExternalDirectoryPermission(extCtx, target, "read", {
34025
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
34026
+ });
34027
+ }
34028
+ }
33685
34029
  function zoomTargetLabel(args) {
33686
34030
  return args.filePath ?? args.url ?? "(no target)";
33687
34031
  }
@@ -33698,22 +34042,24 @@ function buildOutlineSections(text, theme) {
33698
34042
  }
33699
34043
  function buildZoomSections(args, payload, theme) {
33700
34044
  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;
34045
+ const batchItems = Array.isArray(batch?.symbols) ? batch.symbols : Array.isArray(batch?.entries) ? batch.entries : null;
34046
+ if (batchItems) {
34047
+ const header = batch?.complete === false ? [theme.fg("warning", "Incomplete zoom results")] : [];
33704
34048
  return [
33705
34049
  ...header,
33706
- ...items2.map((item) => {
34050
+ ...batchItems.map((item) => {
33707
34051
  const record2 = asRecord2(item);
33708
34052
  if (!record2)
33709
34053
  return theme.fg("muted", "No zoom result available.");
33710
34054
  const name = asString(record2.name) ?? "(unknown symbol)";
34055
+ const itemTargetLabel = asString(record2.targetLabel) ?? zoomTargetLabel(args);
33711
34056
  if (record2.success === false) {
33712
- return theme.fg("error", `Symbol "${name}" not found: ${asString(record2.error) ?? "zoom failed"}`);
34057
+ const location = record2.targetLabel ? ` in ${shortenPath2(itemTargetLabel)}` : "";
34058
+ return theme.fg("error", `Symbol "${name}" not found${location}: ${asString(record2.error) ?? "zoom failed"}`);
33713
34059
  }
33714
34060
  const content = asString(record2.content);
33715
34061
  return [
33716
- `${theme.fg("accent", name)} ${theme.fg("muted", shortenPath(zoomTargetLabel(args)))}`,
34062
+ `${theme.fg("accent", name)} ${theme.fg("muted", shortenPath2(itemTargetLabel))}`,
33717
34063
  content
33718
34064
  ].filter(Boolean).join(`
33719
34065
  `);
@@ -33733,7 +34079,7 @@ function buildZoomSections(args, payload, theme) {
33733
34079
  const startLine = range && typeof range.start_line === "number" ? range.start_line : undefined;
33734
34080
  const endLine = range && typeof range.end_line === "number" ? range.end_line : undefined;
33735
34081
  const targetLabel = zoomTargetLabel(args);
33736
- const location = startLine !== undefined ? `${shortenPath(targetLabel)}:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : shortenPath(targetLabel);
34082
+ const location = startLine !== undefined ? `${shortenPath2(targetLabel)}:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : shortenPath2(targetLabel);
33737
34083
  const lines = [`${theme.fg("accent", name)} ${theme.fg("muted", `[${kind}] ${location}`)}`];
33738
34084
  const content = asString(record2.content);
33739
34085
  if (content) {
@@ -33810,7 +34156,9 @@ Pass a single \`target\`:
33810
34156
  if (target.length === 0) {
33811
34157
  throw new Error("'target' must be a non-empty string or array of strings");
33812
34158
  }
33813
- const response3 = await callBridge(bridge, "outline", { target, files: true }, extCtx);
34159
+ const resolvedTargets = await Promise.all(target.map((entry) => resolvePathArg(extCtx.cwd, entry)));
34160
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34161
+ const response3 = await callBridge(bridge, "outline", { target: resolvedTargets, files: true }, extCtx);
33814
34162
  if (response3.success === false) {
33815
34163
  throw new Error(response3.message || "outline failed");
33816
34164
  }
@@ -33819,13 +34167,14 @@ Pass a single \`target\`:
33819
34167
  if (typeof target !== "string" || target.length === 0) {
33820
34168
  throw new Error("'target' must be a non-empty string or array of strings");
33821
34169
  }
34170
+ const resolvedTarget2 = await resolvePathArg(extCtx.cwd, target);
33822
34171
  let isDirectory2 = false;
33823
34172
  try {
33824
- const resolved = resolve4(extCtx.cwd, target);
33825
- const st = await stat2(resolved);
34173
+ const st = await stat2(resolvedTarget2);
33826
34174
  isDirectory2 = st.isDirectory();
33827
34175
  } catch {}
33828
- const request = isDirectory2 ? { directory: resolve4(extCtx.cwd, target), files: true } : { file: target, files: true };
34176
+ await assertReadPathPermissions(extCtx, ctx, resolvedTarget2);
34177
+ const request = isDirectory2 ? { directory: resolvedTarget2, files: true } : { file: resolvedTarget2, files: true };
33829
34178
  const response2 = await callBridge(bridge, "outline", request, extCtx);
33830
34179
  if (response2.success === false) {
33831
34180
  throw new Error(response2.message || "outline failed");
@@ -33840,24 +34189,26 @@ Pass a single \`target\`:
33840
34189
  return textResult(formatOutlineText(response2));
33841
34190
  }
33842
34191
  if (isArray) {
33843
- const response2 = await callBridge(bridge, "outline", { files: target }, extCtx);
34192
+ const resolvedTargets = await Promise.all(target.map((entry) => resolvePathArg(extCtx.cwd, entry)));
34193
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34194
+ const response2 = await callBridge(bridge, "outline", { files: resolvedTargets }, extCtx);
33844
34195
  return textResult(formatOutlineText(response2));
33845
34196
  }
33846
34197
  if (typeof target !== "string" || target.length === 0) {
33847
34198
  throw new Error("'target' must be a non-empty string or array of strings");
33848
34199
  }
34200
+ const resolvedTarget = await resolvePathArg(extCtx.cwd, target);
33849
34201
  let isDirectory = false;
33850
34202
  try {
33851
- const resolved = resolve4(extCtx.cwd, target);
33852
- const st = await stat2(resolved);
34203
+ const st = await stat2(resolvedTarget);
33853
34204
  isDirectory = st.isDirectory();
33854
34205
  } catch {}
34206
+ await assertReadPathPermissions(extCtx, ctx, resolvedTarget);
33855
34207
  if (isDirectory) {
33856
- const dirPath = resolve4(extCtx.cwd, target);
33857
- const response2 = await callBridge(bridge, "outline", { directory: dirPath }, extCtx);
34208
+ const response2 = await callBridge(bridge, "outline", { directory: resolvedTarget }, extCtx);
33858
34209
  return textResult(JSON.stringify(response2, null, 2), response2);
33859
34210
  }
33860
- const response = await callBridge(bridge, "outline", { file: target }, extCtx);
34211
+ const response = await callBridge(bridge, "outline", { file: resolvedTarget }, extCtx);
33861
34212
  return textResult(formatOutlineText(response));
33862
34213
  },
33863
34214
  renderCall(args, theme, context) {
@@ -33872,7 +34223,7 @@ Pass a single \`target\`:
33872
34223
  pi.registerTool({
33873
34224
  name: "aft_zoom",
33874
34225
  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.",
34226
+ 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
34227
  parameters: ZoomParams,
33877
34228
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
33878
34229
  const bridge = bridgeFor(ctx, extCtx.cwd);
@@ -33896,6 +34247,7 @@ Pass a single \`target\`:
33896
34247
  const hasUrl = !isEmptyParam(params.url);
33897
34248
  const hasTargets = hasTargetsProvided(params.targets);
33898
34249
  const hasSymbols = !isEmptyParam(params.symbols);
34250
+ const wantCallgraph = params.callgraph === true;
33899
34251
  if (hasTargets) {
33900
34252
  if (hasFilePath || hasUrl || hasSymbols) {
33901
34253
  throw new Error("'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'");
@@ -33912,10 +34264,17 @@ Pass a single \`target\`:
33912
34264
  throw new Error(`targets[${i}].symbol must be a non-empty string`);
33913
34265
  }
33914
34266
  }
33915
- const responses = await Promise.all(targets.map((t) => {
33916
- const req2 = { file: t.filePath, symbol: t.symbol };
34267
+ const resolvedTargets = await Promise.all(targets.map((t) => resolvePathArg(extCtx.cwd, t.filePath)));
34268
+ await assertReadPathPermissions(extCtx, ctx, resolvedTargets);
34269
+ const responses = await Promise.all(targets.map((t, index) => {
34270
+ const req2 = {
34271
+ file: resolvedTargets[index],
34272
+ symbol: t.symbol
34273
+ };
33917
34274
  if (params.contextLines !== undefined)
33918
34275
  req2.context_lines = params.contextLines;
34276
+ if (wantCallgraph)
34277
+ req2.callgraph = true;
33919
34278
  return callBridge(bridge, "zoom", req2, extCtx).catch((err) => ({
33920
34279
  success: false,
33921
34280
  message: err instanceof Error ? err.message : String(err)
@@ -33935,7 +34294,9 @@ Pass a single \`target\`:
33935
34294
  if (hasFilePath && hasUrl) {
33936
34295
  throw new Error("Provide exactly ONE of 'filePath' or 'url' — not both");
33937
34296
  }
33938
- const file2 = hasUrl ? params.url : params.filePath;
34297
+ const file2 = hasUrl ? params.url : await resolvePathArg(extCtx.cwd, params.filePath);
34298
+ if (!hasUrl)
34299
+ await assertReadPathPermissions(extCtx, ctx, file2);
33939
34300
  const targetLabel = (hasUrl ? params.url : params.filePath) ?? file2;
33940
34301
  const symbolsArray = hasSymbols ? typeof params.symbols === "string" ? [params.symbols] : params.symbols : undefined;
33941
34302
  if (symbolsArray) {
@@ -33943,6 +34304,8 @@ Pass a single \`target\`:
33943
34304
  const req2 = { file: file2, symbol: sym };
33944
34305
  if (params.contextLines !== undefined)
33945
34306
  req2.context_lines = params.contextLines;
34307
+ if (wantCallgraph)
34308
+ req2.callgraph = true;
33946
34309
  return callBridge(bridge, "zoom", req2, extCtx).catch((err) => ({
33947
34310
  success: false,
33948
34311
  message: err instanceof Error ? err.message : String(err)
@@ -33961,6 +34324,8 @@ Pass a single \`target\`:
33961
34324
  const req = { file: file2 };
33962
34325
  if (params.contextLines !== undefined)
33963
34326
  req.context_lines = params.contextLines;
34327
+ if (wantCallgraph)
34328
+ req.callgraph = true;
33964
34329
  const response = await callBridge(bridge, "zoom", req, extCtx);
33965
34330
  if (response.success === false) {
33966
34331
  throw new Error(response.message || "zoom failed");
@@ -34073,21 +34438,21 @@ function buildRefactorSections(args, payload, theme) {
34073
34438
  `${theme.fg("success", "moved symbol")} ${theme.fg("toolOutput", args.symbol ?? "(symbol)")}`,
34074
34439
  `${theme.fg("muted", "files modified")} ${asNumber(response.files_modified) ?? results.length}`,
34075
34440
  `${theme.fg("muted", "consumers updated")} ${asNumber(response.consumers_updated) ?? 0}`,
34076
- results.length > 0 ? results.map((entry) => ` ↳ ${shortenPath(asString(entry.file) ?? "(unknown file)")}`).join(`
34441
+ results.length > 0 ? results.map((entry) => ` ↳ ${shortenPath2(asString(entry.file) ?? "(unknown file)")}`).join(`
34077
34442
  `) : theme.fg("muted", "No files reported.")
34078
34443
  ];
34079
34444
  }
34080
34445
  if (args.op === "extract") {
34081
34446
  return [
34082
34447
  `${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))}`,
34448
+ `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath))}`,
34084
34449
  `${theme.fg("muted", "params")} ${Array.isArray(response.parameters) ? response.parameters.join(", ") || "none" : "none"}`,
34085
34450
  `${theme.fg("muted", "return type")} ${asString(response.return_type) ?? "unknown"}`
34086
34451
  ];
34087
34452
  }
34088
34453
  return [
34089
34454
  `${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))}`,
34455
+ `${theme.fg("muted", "file")} ${theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath))}`,
34091
34456
  `${theme.fg("muted", "context")} ${asString(response.call_context) ?? "unknown"}`,
34092
34457
  `${theme.fg("muted", "substitutions")} ${asNumber(response.substitutions) ?? 0}`
34093
34458
  ];
@@ -34112,7 +34477,6 @@ function registerRefactorTool(pi, ctx) {
34112
34477
  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
34478
  parameters: RefactorParams,
34114
34479
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
34115
- const bridge = bridgeFor(ctx, extCtx.cwd);
34116
34480
  const commandMap = {
34117
34481
  move: "move_symbol",
34118
34482
  extract: "extract_function",
@@ -34127,18 +34491,40 @@ function registerRefactorTool(pi, ctx) {
34127
34491
  if (params.op === "extract" && isEmptyParam(params.name)) {
34128
34492
  throw new Error("'name' is required for 'extract' op");
34129
34493
  }
34130
- const req = { file: params.filePath };
34494
+ const startLine = coerceOptionalInt(params.startLine, "startLine", 1, Number.MAX_SAFE_INTEGER);
34495
+ const endLine = coerceOptionalInt(params.endLine, "endLine", 1, Number.MAX_SAFE_INTEGER);
34496
+ const callSiteLine = coerceOptionalInt(params.callSiteLine, "callSiteLine", 1, Number.MAX_SAFE_INTEGER);
34497
+ if (params.op === "extract") {
34498
+ if (startLine === undefined)
34499
+ throw new Error("'startLine' is required for 'extract' op");
34500
+ if (endLine === undefined)
34501
+ throw new Error("'endLine' is required for 'extract' op");
34502
+ }
34503
+ if (params.op === "inline" && callSiteLine === undefined) {
34504
+ throw new Error("'callSiteLine' is required for 'inline' op");
34505
+ }
34506
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
34507
+ const destination = !isEmptyParam(params.destination) ? await resolvePathArg(extCtx.cwd, params.destination) : undefined;
34508
+ const permissionTargets = params.op === "move" && destination !== undefined ? [filePath, destination] : [filePath];
34509
+ const checked = new Set;
34510
+ for (const target of permissionTargets) {
34511
+ if (checked.has(target))
34512
+ continue;
34513
+ checked.add(target);
34514
+ await assertExternalDirectoryPermission(extCtx, target, "modify", {
34515
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
34516
+ });
34517
+ }
34518
+ const bridge = bridgeFor(ctx, extCtx.cwd);
34519
+ const req = { file: filePath };
34131
34520
  if (!isEmptyParam(params.symbol))
34132
34521
  req.symbol = params.symbol;
34133
- if (!isEmptyParam(params.destination))
34134
- req.destination = params.destination;
34522
+ if (destination !== undefined)
34523
+ req.destination = destination;
34135
34524
  if (!isEmptyParam(params.scope))
34136
34525
  req.scope = params.scope;
34137
34526
  if (!isEmptyParam(params.name))
34138
34527
  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
34528
  if (startLine !== undefined)
34143
34529
  req.start_line = startLine;
34144
34530
  if (endLine !== undefined) {
@@ -34161,6 +34547,9 @@ function registerRefactorTool(pi, ctx) {
34161
34547
  // src/tools/safety.ts
34162
34548
  import { StringEnum as StringEnum5 } from "@earendil-works/pi-ai";
34163
34549
  import { Type as Type12 } from "typebox";
34550
+ function responsePaths(response) {
34551
+ return Array.isArray(response.paths) ? response.paths.filter((path2) => typeof path2 === "string" && path2.length > 0) : [];
34552
+ }
34164
34553
  var SafetyParams = Type12.Object({
34165
34554
  op: StringEnum5(["undo", "history", "checkpoint", "restore", "list"], {
34166
34555
  description: "Safety operation"
@@ -34185,14 +34574,14 @@ function buildSafetySections(args, payload, theme) {
34185
34574
  ];
34186
34575
  }
34187
34576
  return [
34188
- `${theme.fg("success", "restored")} ${theme.fg("accent", shortenPath(asString(response.path) ?? args.filePath ?? "(file)"))}`,
34577
+ `${theme.fg("success", "restored")} ${theme.fg("accent", shortenPath2(asString(response.path) ?? args.filePath ?? "(file)"))}`,
34189
34578
  `${theme.fg("muted", "backup")} ${asString(response.backup_id) ?? "—"}`
34190
34579
  ];
34191
34580
  }
34192
34581
  if (args.op === "history") {
34193
34582
  const entries = asRecords(response.entries);
34194
34583
  const sections2 = [
34195
- theme.fg("accent", shortenPath(asString(response.file) ?? args.filePath ?? "(file)"))
34584
+ theme.fg("accent", shortenPath2(asString(response.file) ?? args.filePath ?? "(file)"))
34196
34585
  ];
34197
34586
  if (entries.length === 0) {
34198
34587
  sections2.push(theme.fg("muted", "No history entries."));
@@ -34214,7 +34603,7 @@ function buildSafetySections(args, payload, theme) {
34214
34603
  `${theme.fg("success", "checkpoint created")} ${theme.fg("accent", asString(response.name) ?? args.name ?? "(checkpoint)")}`,
34215
34604
  `${theme.fg("muted", "files")} ${asNumber(response.file_count) ?? 0}`,
34216
34605
  skipped.length > 0 ? `${theme.fg("warning", "skipped")}
34217
- ${skipped.map((entry) => ` ↳ ${shortenPath(asString(entry.file) ?? "(file)")}: ${asString(entry.error) ?? "unknown error"}`).join(`
34606
+ ${skipped.map((entry) => ` ↳ ${shortenPath2(asString(entry.file) ?? "(file)")}: ${asString(entry.error) ?? "unknown error"}`).join(`
34218
34607
  `)}` : theme.fg("muted", "No skipped files.")
34219
34608
  ];
34220
34609
  }
@@ -34264,7 +34653,43 @@ function registerSafetyTool(pi, ctx) {
34264
34653
  if ((params.op === "checkpoint" || params.op === "restore") && !params.name) {
34265
34654
  throw new Error(`op='${params.op}' requires 'name'`);
34266
34655
  }
34656
+ const filePath = params.filePath ? await resolvePathArg(extCtx.cwd, params.filePath) : undefined;
34657
+ const files = params.files ? await Promise.all(params.files.map((file2) => resolvePathArg(extCtx.cwd, file2))) : undefined;
34267
34658
  const bridge = bridgeFor(ctx, extCtx.cwd);
34659
+ const restrictToProjectRoot = ctx.config.restrict_to_project_root ?? false;
34660
+ if (params.op === "undo") {
34661
+ const previewReq = {};
34662
+ if (filePath)
34663
+ previewReq.file = filePath;
34664
+ const preview = await callBridge(bridge, "undo_preview", previewReq, extCtx);
34665
+ for (const file2 of new Set(responsePaths(preview))) {
34666
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34667
+ restrictToProjectRoot
34668
+ });
34669
+ }
34670
+ }
34671
+ if (params.op === "checkpoint") {
34672
+ const checkpointFiles = files ?? (filePath ? [filePath] : undefined);
34673
+ if (Array.isArray(checkpointFiles)) {
34674
+ const checked = new Set;
34675
+ for (const file2 of checkpointFiles) {
34676
+ if (checked.has(file2))
34677
+ continue;
34678
+ checked.add(file2);
34679
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34680
+ restrictToProjectRoot
34681
+ });
34682
+ }
34683
+ }
34684
+ }
34685
+ if (params.op === "restore" && params.name) {
34686
+ const preview = await callBridge(bridge, "checkpoint_paths", { name: params.name }, extCtx);
34687
+ for (const file2 of new Set(responsePaths(preview))) {
34688
+ await assertExternalDirectoryPermission(extCtx, file2, "modify", {
34689
+ restrictToProjectRoot
34690
+ });
34691
+ }
34692
+ }
34268
34693
  const commandMap = {
34269
34694
  undo: "undo",
34270
34695
  history: "edit_history",
@@ -34276,16 +34701,16 @@ function registerSafetyTool(pi, ctx) {
34276
34701
  if (params.name)
34277
34702
  req.name = params.name;
34278
34703
  if (params.op === "checkpoint") {
34279
- if (params.files) {
34280
- req.files = params.files;
34281
- } else if (params.filePath) {
34282
- req.files = [params.filePath];
34704
+ if (files) {
34705
+ req.files = files;
34706
+ } else if (filePath) {
34707
+ req.files = [filePath];
34283
34708
  }
34284
34709
  } else {
34285
- if (params.filePath)
34286
- req.file = params.filePath;
34287
- if (params.files)
34288
- req.files = params.files;
34710
+ if (filePath)
34711
+ req.file = filePath;
34712
+ if (files)
34713
+ req.files = files;
34289
34714
  }
34290
34715
  const response = await callBridge(bridge, commandMap[params.op], req, extCtx);
34291
34716
  return textResult(JSON.stringify(response, null, 2));
@@ -34362,7 +34787,7 @@ function buildSemanticSections(args, payload, theme) {
34362
34787
  }
34363
34788
  const grouped = groupByFile(results, (result) => asString(result.file));
34364
34789
  for (const [file2, fileResults] of grouped.entries()) {
34365
- const lines = [theme.fg("accent", shortenPath(file2))];
34790
+ const lines = [theme.fg("accent", shortenPath2(file2))];
34366
34791
  fileResults.forEach((result) => {
34367
34792
  if (asString(result.kind) === "GrepLine") {
34368
34793
  const line = asNumber(result.line);
@@ -34516,8 +34941,12 @@ function registerStructureTool(pi, ctx) {
34516
34941
  parameters: TransformParams,
34517
34942
  async execute(_toolCallId, params, _signal, _onUpdate, extCtx) {
34518
34943
  validateTransformParams(params);
34944
+ const filePath = await resolvePathArg(extCtx.cwd, params.filePath);
34945
+ await assertExternalDirectoryPermission(extCtx, filePath, "modify", {
34946
+ restrictToProjectRoot: ctx.config.restrict_to_project_root ?? false
34947
+ });
34519
34948
  const bridge = bridgeFor(ctx, extCtx.cwd);
34520
- const req = { file: params.filePath };
34949
+ const req = { file: filePath };
34521
34950
  if (params.container !== undefined)
34522
34951
  req.scope = params.container;
34523
34952
  if (params.code !== undefined)
@@ -34554,32 +34983,32 @@ function registerStructureTool(pi, ctx) {
34554
34983
  function validateTransformParams(params) {
34555
34984
  const op = params.op;
34556
34985
  if (op === "add_member") {
34557
- if (typeof params.container !== "string") {
34986
+ if (isEmptyParam(params.container)) {
34558
34987
  throw new Error("'container' is required for 'add_member' op");
34559
34988
  }
34560
- if (typeof params.code !== "string") {
34989
+ if (isEmptyParam(params.code)) {
34561
34990
  throw new Error("'code' is required for 'add_member' op");
34562
34991
  }
34563
34992
  }
34564
34993
  if (op === "add_derive" || op === "wrap_try_catch" || op === "add_decorator" || op === "add_struct_tags") {
34565
- if (typeof params.target !== "string") {
34994
+ if (isEmptyParam(params.target)) {
34566
34995
  throw new Error(`'target' is required for '${op}' op`);
34567
34996
  }
34568
34997
  }
34569
- if (op === "add_derive" && !Array.isArray(params.derives)) {
34998
+ if (op === "add_derive" && isEmptyParam(params.derives)) {
34570
34999
  throw new Error("'derives' array is required for 'add_derive' op");
34571
35000
  }
34572
- if (op === "add_decorator" && typeof params.decorator !== "string") {
35001
+ if (op === "add_decorator" && isEmptyParam(params.decorator)) {
34573
35002
  throw new Error("'decorator' is required for 'add_decorator' op");
34574
35003
  }
34575
35004
  if (op === "add_struct_tags") {
34576
- if (typeof params.field !== "string") {
35005
+ if (isEmptyParam(params.field)) {
34577
35006
  throw new Error("'field' is required for 'add_struct_tags' op");
34578
35007
  }
34579
- if (typeof params.tag !== "string") {
35008
+ if (isEmptyParam(params.tag)) {
34580
35009
  throw new Error("'tag' is required for 'add_struct_tags' op");
34581
35010
  }
34582
- if (typeof params.value !== "string") {
35011
+ if (isEmptyParam(params.value)) {
34583
35012
  throw new Error("'value' is required for 'add_struct_tags' op");
34584
35013
  }
34585
35014
  }
@@ -34611,6 +35040,7 @@ function buildWorkflowHints(opts) {
34611
35040
  }
34612
35041
  if (hasInspect) {
34613
35042
  sections.push("**Codebase health & diagnostics**: AFT does not surface compile/type errors automatically after edits — pull them with `aft_inspect`. Run it after a batch of edits and before you run tests or commit, when starting in unfamiliar code, or before a refactor/review. One call summarizes diagnostics (compile/type errors), TODOs, metrics, dead code, unused exports, and duplicates; pass `sections` for focused drill-down and `scope` to actively pull diagnostics for a specific file or directory. Its diagnostics are a fast checkpoint, not the authority — a clean `tsc` / `cargo check` / `pyright` run is the real gate. Treat `stale_categories` as a genuine stale-cache signal while an async Tier 2 refresh catches up.");
35043
+ sections.push("**AFT status bar**: tool results may end with a one-line health bar `[AFT E<errors> W<warnings> | D<dead-code> U<unused-exports> C<clone/dup-groups> | T<todos>]` — an IDE-style glance that appears when a count changes. `E`/`W` are live LSP diagnostics for files touched this session (your universal compile-error signal across every language with an LSP). A `~` before `D` means the dead-code/unused/dup counts predate your latest edit — run `aft_inspect` for current numbers and detail. When `E>0`, you likely just introduced errors; investigate before moving on.");
34614
35044
  }
34615
35045
  if (hasNavigate) {
34616
35046
  sections.push([
@@ -34720,15 +35150,16 @@ var PLUGIN_VERSION = (() => {
34720
35150
  return "0.0.0";
34721
35151
  }
34722
35152
  })();
34723
- var ANNOUNCEMENT_VERSION = "0.33.0";
35153
+ var ANNOUNCEMENT_VERSION = "0.35.0";
34724
35154
  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."
35155
+ "Leaner semantic indexing local embedding now uses about half the CPU and a fraction of the peak memory, with no change to results and no re-index needed.",
35156
+ "New IDE-style status bar on tool results live LSP errors/warnings plus dead-code, unused-export, duplicate, and TODO counts at a glance.",
35157
+ "`aft_inspect` is more accurate (dead-code reachability, duplicate collapse, ranked findings) and a background scheduler keeps its counts fresh during long sessions.",
35158
+ "The `aft_conflicts` and `grep` bash-output hints now fire on Pi too.",
35159
+ "`npx @cortexkit/aft --version` reports CLI, binary, and per-harness versions; `doctor --issue` can scope its report to a single session.",
35160
+ "New SCSS support and `.inc` files parsed as PHP."
34730
35161
  ];
34731
- var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/F2uWxjGnU";
35162
+ var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/DSa65w8wuf";
34732
35163
  var ALL_ONLY_TOOLS = new Set([
34733
35164
  "aft_callgraph",
34734
35165
  "aft_delete",
@@ -35033,7 +35464,7 @@ ${lines}
35033
35464
  if (onnxRuntimePromise) {
35034
35465
  await Promise.race([
35035
35466
  onnxRuntimePromise,
35036
- new Promise((resolve5) => setTimeout(() => resolve5(null), 60000))
35467
+ new Promise((resolve4) => setTimeout(() => resolve4(null), 60000))
35037
35468
  ]);
35038
35469
  }
35039
35470
  const bridge = pool.getBridge(cwd);
@@ -35089,8 +35520,14 @@ ${lines}
35089
35520
  registerWorkflowHints(pi, config2, surface);
35090
35521
  registerStatusCommand(pi, ctx);
35091
35522
  pi.on("tool_result", async (event, extCtx) => {
35092
- const content = await appendToolResultBgCompletions({ ctx, directory: extCtx.cwd, sessionID: resolveSessionId(extCtx) }, event.content);
35093
- if (!content)
35523
+ const sessionID = resolveSessionId(extCtx);
35524
+ const bgContent = await appendToolResultBgCompletions({ ctx, directory: extCtx.cwd, sessionID }, event.content);
35525
+ let content = bgContent ?? event.content;
35526
+ const activeBridge = pool.getActiveBridgeForRoot(extCtx.cwd);
35527
+ const bar = statusBarBlockForSession(sessionID, activeBridge?.getStatusBar());
35528
+ if (bar)
35529
+ content = [...content, { type: "text", text: bar }];
35530
+ if (content === event.content)
35094
35531
  return;
35095
35532
  return { content, details: event.details, isError: event.isError };
35096
35533
  });