@cortexkit/aft-opencode 0.36.1 → 0.37.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
@@ -11597,6 +11597,9 @@ function error(message, meta) {
11597
11597
  var CONFLICT_HINT = `
11598
11598
 
11599
11599
  [Hint] Use aft_conflicts to see all conflict regions across files in a single call.`;
11600
+ var GREP_SEARCH_AFT_SEARCH_HINT = "DO NOT search code by running grep/rg in bash — it is unindexed, unranked, and serial. Use the `aft_search` tool instead (it auto-routes concepts, identifiers, regex, and literals).";
11601
+ var GREP_SEARCH_GREP_HINT = "DO NOT search code by running grep/rg in bash — it is unindexed, unranked, and serial. Use the `grep` tool instead (indexed and ranked).";
11602
+ var GREP_SEARCH_HINT_PREFIX = "DO NOT search code by running grep/rg in bash —";
11600
11603
  function maybeAppendConflictsHint(output) {
11601
11604
  if (!output.includes("Automatic merge failed; fix conflicts"))
11602
11605
  return output;
@@ -11604,6 +11607,146 @@ function maybeAppendConflictsHint(output) {
11604
11607
  return output;
11605
11608
  return output + CONFLICT_HINT;
11606
11609
  }
11610
+ function commandLeadsWithCodeSearch(command) {
11611
+ const trimmed = command.trim();
11612
+ if (!trimmed)
11613
+ return false;
11614
+ const afterCd = peelLeadingCdAnd(trimmed);
11615
+ if (afterCd === null)
11616
+ return false;
11617
+ const firstStage = firstPipelineStage(afterCd);
11618
+ if (firstStage === null)
11619
+ return false;
11620
+ const firstToken = readShellToken(firstStage, skipSpaces(firstStage, 0));
11621
+ if (firstToken === null)
11622
+ return false;
11623
+ return firstToken.token === "grep" || firstToken.token === "rg";
11624
+ }
11625
+ function maybeAppendGrepSearchHint(output, command, aftSearchRegistered) {
11626
+ if (output === "")
11627
+ return output;
11628
+ if (!commandLeadsWithCodeSearch(command))
11629
+ return output;
11630
+ if (output.includes(GREP_SEARCH_HINT_PREFIX))
11631
+ return output;
11632
+ const hint = aftSearchRegistered ? GREP_SEARCH_AFT_SEARCH_HINT : GREP_SEARCH_GREP_HINT;
11633
+ return `${output}
11634
+
11635
+ ${hint}`;
11636
+ }
11637
+ function peelLeadingCdAnd(command) {
11638
+ const first = readShellToken(command, skipSpaces(command, 0));
11639
+ if (first === null)
11640
+ return null;
11641
+ if (first.token !== "cd")
11642
+ return command;
11643
+ const dir = readShellToken(command, skipSpaces(command, first.end));
11644
+ if (dir === null)
11645
+ return null;
11646
+ if (!dir.token)
11647
+ return command;
11648
+ const afterDir = skipSpaces(command, dir.end);
11649
+ if (!command.startsWith("&&", afterDir))
11650
+ return command;
11651
+ return command.slice(afterDir + 2).trim();
11652
+ }
11653
+ function firstPipelineStage(command) {
11654
+ let quote = "none";
11655
+ let firstPipeIndex;
11656
+ for (let index = 0;index < command.length; index++) {
11657
+ const ch = command[index];
11658
+ if (quote === "single") {
11659
+ if (ch === "'")
11660
+ quote = "none";
11661
+ continue;
11662
+ }
11663
+ if (quote === "double") {
11664
+ if (ch === '"') {
11665
+ quote = "none";
11666
+ } else if (ch === "\\") {
11667
+ index++;
11668
+ } else if (ch === "`") {
11669
+ return null;
11670
+ }
11671
+ continue;
11672
+ }
11673
+ if (ch === "'") {
11674
+ quote = "single";
11675
+ } else if (ch === '"') {
11676
+ quote = "double";
11677
+ } else if (ch === "\\") {
11678
+ index++;
11679
+ } else if (ch === "`") {
11680
+ return null;
11681
+ } else if (ch === "|") {
11682
+ if (command[index + 1] === "|") {
11683
+ index++;
11684
+ } else if (firstPipeIndex === undefined) {
11685
+ firstPipeIndex = index;
11686
+ }
11687
+ }
11688
+ }
11689
+ if (quote !== "none")
11690
+ return null;
11691
+ return command.slice(0, firstPipeIndex ?? command.length).trim();
11692
+ }
11693
+ function readShellToken(command, start) {
11694
+ let quote = "none";
11695
+ let token = "";
11696
+ let index = start;
11697
+ for (;index < command.length; index++) {
11698
+ const ch = command[index];
11699
+ if (quote === "single") {
11700
+ if (ch === "'") {
11701
+ quote = "none";
11702
+ } else {
11703
+ token += ch;
11704
+ }
11705
+ continue;
11706
+ }
11707
+ if (quote === "double") {
11708
+ if (ch === '"') {
11709
+ quote = "none";
11710
+ } else if (ch === "\\") {
11711
+ index++;
11712
+ token += command[index] ?? "\\";
11713
+ } else if (ch === "`") {
11714
+ return null;
11715
+ } else {
11716
+ token += ch;
11717
+ }
11718
+ continue;
11719
+ }
11720
+ if (/\s/.test(ch))
11721
+ break;
11722
+ if (isTokenBoundary(ch))
11723
+ break;
11724
+ if (ch === "'") {
11725
+ quote = "single";
11726
+ } else if (ch === '"') {
11727
+ quote = "double";
11728
+ } else if (ch === "\\") {
11729
+ index++;
11730
+ token += command[index] ?? "\\";
11731
+ } else if (ch === "`") {
11732
+ return null;
11733
+ } else {
11734
+ token += ch;
11735
+ }
11736
+ }
11737
+ if (quote !== "none")
11738
+ return null;
11739
+ return { token, end: index };
11740
+ }
11741
+ function isTokenBoundary(ch) {
11742
+ return ch === "|" || ch === ";" || ch === "&" || ch === "<" || ch === ">";
11743
+ }
11744
+ function skipSpaces(input, start) {
11745
+ let index = start;
11746
+ while (index < input.length && /\s/.test(input[index]))
11747
+ index++;
11748
+ return index;
11749
+ }
11607
11750
  // ../aft-bridge/dist/bash-timeout.js
11608
11751
  function resolveBashKillTimeout(modelTimeout, foregroundWaitMs) {
11609
11752
  if (modelTimeout !== undefined && modelTimeout >= foregroundWaitMs) {
@@ -11617,6 +11760,21 @@ import { homedir } from "node:os";
11617
11760
  import { join } from "node:path";
11618
11761
  import { StringDecoder } from "node:string_decoder";
11619
11762
 
11763
+ // ../aft-bridge/dist/command-timeouts.js
11764
+ var LONG_RUNNING_COMMAND_TIMEOUT_MS = {
11765
+ callers: 60000,
11766
+ trace_to: 60000,
11767
+ trace_to_symbol: 60000,
11768
+ trace_data: 60000,
11769
+ impact: 60000,
11770
+ grep: 60000,
11771
+ glob: 60000,
11772
+ semantic_search: 60000
11773
+ };
11774
+ function timeoutForCommand(command) {
11775
+ return LONG_RUNNING_COMMAND_TIMEOUT_MS[command];
11776
+ }
11777
+
11620
11778
  // ../aft-bridge/dist/status-bar.js
11621
11779
  var STATUS_BAR_HEARTBEAT_CALLS = 15;
11622
11780
  function createStatusBarEmitState() {
@@ -11669,6 +11827,27 @@ var DEFAULT_BRIDGE_TIMEOUT_MS = 30000;
11669
11827
  var BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
11670
11828
  var SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5000;
11671
11829
  var MAX_STDOUT_BUFFER = 64 * 1024 * 1024;
11830
+ var STDOUT_BUFFER_COMPACT_THRESHOLD = 64 * 1024;
11831
+ var TERMINAL_BASH_STATUSES = new Set([
11832
+ "completed",
11833
+ "failed",
11834
+ "killed",
11835
+ "timed_out",
11836
+ "cancelled",
11837
+ "timeout"
11838
+ ]);
11839
+ function isTerminalBashStatus(status) {
11840
+ return typeof status === "string" && TERMINAL_BASH_STATUSES.has(status);
11841
+ }
11842
+ function bashTaskIdFrom(response) {
11843
+ const snakeCase = response.task_id;
11844
+ if (typeof snakeCase === "string" && snakeCase.length > 0)
11845
+ return snakeCase;
11846
+ const camelCase = response.taskId;
11847
+ if (typeof camelCase === "string" && camelCase.length > 0)
11848
+ return camelCase;
11849
+ return;
11850
+ }
11672
11851
  function tagStderrLine(line) {
11673
11852
  return /^\[aft(-\w+)?\] /.test(line) ? line : `[aft] ${line}`;
11674
11853
  }
@@ -11723,11 +11902,12 @@ function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
11723
11902
  if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
11724
11903
  return configOverrides;
11725
11904
  }
11726
- const maxSemanticTimeoutMs = bridgeTimeoutMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS ? bridgeTimeoutMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS : Math.max(1, bridgeTimeoutMs - 1);
11905
+ const semanticTransportBudgetMs = Math.max(bridgeTimeoutMs, LONG_RUNNING_COMMAND_TIMEOUT_MS.semantic_search ?? 0);
11906
+ const maxSemanticTimeoutMs = semanticTransportBudgetMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS ? semanticTransportBudgetMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS : Math.max(1, semanticTransportBudgetMs - 1);
11727
11907
  if (timeoutMs <= maxSemanticTimeoutMs) {
11728
11908
  return configOverrides;
11729
11909
  }
11730
- warn(`semantic.timeout_ms=${timeoutMs} exceeds bridge timeout budget; clamping to ${maxSemanticTimeoutMs}ms (bridge timeout: ${bridgeTimeoutMs}ms)`);
11910
+ warn(`semantic.timeout_ms=${timeoutMs} exceeds the semantic transport budget; clamping to ${maxSemanticTimeoutMs}ms (budget: ${semanticTransportBudgetMs}ms)`);
11731
11911
  return {
11732
11912
  ...configOverrides,
11733
11913
  semantic: {
@@ -11753,8 +11933,10 @@ class BinaryBridge {
11753
11933
  cwd;
11754
11934
  process = null;
11755
11935
  pending = new Map;
11936
+ outstandingBackgroundTaskIds = new Set;
11756
11937
  nextId = 1;
11757
11938
  stdoutBuffer = "";
11939
+ stdoutReadOffset = 0;
11758
11940
  stderrBuffer = "";
11759
11941
  stderrTail = [];
11760
11942
  _restartCount = 0;
@@ -11864,6 +12046,9 @@ class BinaryBridge {
11864
12046
  hasPendingRequests() {
11865
12047
  return this.pending.size > 0;
11866
12048
  }
12049
+ hasOutstandingBackgroundTasks() {
12050
+ return this.outstandingBackgroundTaskIds.size > 0;
12051
+ }
11867
12052
  getCwd() {
11868
12053
  return this.cwd;
11869
12054
  }
@@ -11985,7 +12170,7 @@ class BinaryBridge {
11985
12170
  entry.reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
11986
12171
  this.handleTimeout(requestSessionId);
11987
12172
  }, effectiveTimeoutMs);
11988
- this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
12173
+ this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress, command });
11989
12174
  if (!this.process?.stdin?.writable) {
11990
12175
  this.pending.delete(id);
11991
12176
  clearTimeout(timer);
@@ -12246,6 +12431,7 @@ class BinaryBridge {
12246
12431
  });
12247
12432
  this.process = child;
12248
12433
  this.stdoutBuffer = "";
12434
+ this.stdoutReadOffset = 0;
12249
12435
  this.stderrBuffer = "";
12250
12436
  this.lastChildActivityAt = 0;
12251
12437
  this.consecutiveRequestTimeouts = 0;
@@ -12290,24 +12476,41 @@ class BinaryBridge {
12290
12476
  ${tail}`;
12291
12477
  }
12292
12478
  onStdoutData(data) {
12479
+ if (this.stdoutReadOffset > STDOUT_BUFFER_COMPACT_THRESHOLD) {
12480
+ this.compactStdoutBuffer();
12481
+ }
12293
12482
  this.stdoutBuffer += data;
12294
- if (this.stdoutBuffer.length > MAX_STDOUT_BUFFER) {
12483
+ if (this.stdoutBuffer.length - this.stdoutReadOffset > MAX_STDOUT_BUFFER) {
12295
12484
  this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
12296
12485
  return;
12297
12486
  }
12298
12487
  let newlineIdx;
12299
12488
  while ((newlineIdx = this.stdoutBuffer.indexOf(`
12300
- `)) !== -1) {
12301
- const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
12302
- this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
12303
- if (!line)
12304
- continue;
12305
- this.processStdoutLine(line);
12489
+ `, this.stdoutReadOffset)) !== -1) {
12490
+ const line = this.stdoutBuffer.slice(this.stdoutReadOffset, newlineIdx).trim();
12491
+ this.stdoutReadOffset = newlineIdx + 1;
12492
+ if (line) {
12493
+ this.processStdoutLine(line);
12494
+ }
12495
+ if (this.stdoutReadOffset > STDOUT_BUFFER_COMPACT_THRESHOLD && this.stdoutReadOffset > this.stdoutBuffer.length / 2) {
12496
+ this.compactStdoutBuffer();
12497
+ }
12306
12498
  }
12499
+ if (this.stdoutReadOffset === this.stdoutBuffer.length) {
12500
+ this.stdoutBuffer = "";
12501
+ this.stdoutReadOffset = 0;
12502
+ }
12503
+ }
12504
+ compactStdoutBuffer() {
12505
+ if (this.stdoutReadOffset === 0)
12506
+ return;
12507
+ this.stdoutBuffer = this.stdoutBuffer.slice(this.stdoutReadOffset);
12508
+ this.stdoutReadOffset = 0;
12307
12509
  }
12308
12510
  flushStdoutBuffer() {
12309
- const line = this.stdoutBuffer.trim();
12511
+ const line = this.stdoutBuffer.slice(this.stdoutReadOffset).trim();
12310
12512
  this.stdoutBuffer = "";
12513
+ this.stdoutReadOffset = 0;
12311
12514
  if (!line)
12312
12515
  return;
12313
12516
  this.processStdoutLine(line);
@@ -12340,6 +12543,9 @@ class BinaryBridge {
12340
12543
  return;
12341
12544
  }
12342
12545
  if (response.type === "bash_completed") {
12546
+ const taskId = bashTaskIdFrom(response);
12547
+ if (taskId)
12548
+ this.outstandingBackgroundTaskIds.delete(taskId);
12343
12549
  this.onBashCompletion?.(response, this);
12344
12550
  return;
12345
12551
  }
@@ -12370,6 +12576,7 @@ class BinaryBridge {
12370
12576
  clearTimeout(entry.timer);
12371
12577
  this.consecutiveRequestTimeouts = 0;
12372
12578
  this.scheduleRestartCountReset();
12579
+ this.accountForBashTaskResponse(entry.command, response);
12373
12580
  this.captureStatusBar(response);
12374
12581
  entry.resolve(response);
12375
12582
  } else if (typeof response.type === "string") {
@@ -12379,6 +12586,18 @@ class BinaryBridge {
12379
12586
  this.warnVia(`Failed to parse stdout line: ${line}`);
12380
12587
  }
12381
12588
  }
12589
+ accountForBashTaskResponse(command, response) {
12590
+ const taskId = bashTaskIdFrom(response);
12591
+ if (!taskId)
12592
+ return;
12593
+ if (isTerminalBashStatus(response.status)) {
12594
+ this.outstandingBackgroundTaskIds.delete(taskId);
12595
+ return;
12596
+ }
12597
+ if (command === "bash" && response.success !== false) {
12598
+ this.outstandingBackgroundTaskIds.add(taskId);
12599
+ }
12600
+ }
12382
12601
  captureStatusBar(response) {
12383
12602
  const parsed = parseStatusBarCounts(response.status_bar);
12384
12603
  if (parsed)
@@ -12390,6 +12609,7 @@ class BinaryBridge {
12390
12609
  handleTimeout(triggeringSessionId) {
12391
12610
  this.consecutiveRequestTimeouts = 0;
12392
12611
  this.rejectAllPending(new Error(`${this.errorPrefix} bridge killed during sibling timeout — request aborted`));
12612
+ this.outstandingBackgroundTaskIds.clear();
12393
12613
  if (this.process) {
12394
12614
  this.process.kill("SIGKILL");
12395
12615
  this.process = null;
@@ -12419,6 +12639,7 @@ class BinaryBridge {
12419
12639
  }
12420
12640
  this.clearRestartResetTimer();
12421
12641
  this.configured = false;
12642
+ this.outstandingBackgroundTaskIds.clear();
12422
12643
  const tail = this.formatStderrTail();
12423
12644
  if (tail) {
12424
12645
  this.errorVia(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
@@ -14458,7 +14679,7 @@ function tokenizeStage(stage) {
14458
14679
  }
14459
14680
  // ../aft-bridge/dist/pool.js
14460
14681
  import { realpathSync as realpathSync2 } from "node:fs";
14461
- var DEFAULT_IDLE_TIMEOUT_MS = Infinity;
14682
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
14462
14683
  var DEFAULT_MAX_POOL_SIZE = 8;
14463
14684
  var CLEANUP_INTERVAL_MS = 60 * 1000;
14464
14685
  class BridgePool {
@@ -14532,7 +14753,7 @@ class BridgePool {
14532
14753
  cleanup() {
14533
14754
  const now = Date.now();
14534
14755
  for (const [dir, entry] of this.bridges) {
14535
- if (entry.bridge.hasPendingRequests())
14756
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14536
14757
  continue;
14537
14758
  if (now - entry.lastUsed > this.idleTimeoutMs) {
14538
14759
  entry.bridge.shutdown().catch((err) => this.error("cleanup shutdown failed:", err));
@@ -14540,7 +14761,7 @@ class BridgePool {
14540
14761
  }
14541
14762
  }
14542
14763
  for (const bridge of this.staleBridges) {
14543
- if (bridge.hasPendingRequests())
14764
+ if (bridge.hasPendingRequests() || bridge.hasOutstandingBackgroundTasks())
14544
14765
  continue;
14545
14766
  bridge.shutdown().catch((err) => this.error("stale cleanup shutdown failed:", err));
14546
14767
  this.staleBridges.delete(bridge);
@@ -14550,7 +14771,7 @@ class BridgePool {
14550
14771
  let oldestDir = null;
14551
14772
  let oldestTime = Infinity;
14552
14773
  for (const [dir, entry] of this.bridges) {
14553
- if (entry.bridge.hasPendingRequests())
14774
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14554
14775
  continue;
14555
14776
  if (entry.lastUsed < oldestTime) {
14556
14777
  oldestTime = entry.lastUsed;
@@ -32881,7 +33102,14 @@ function writeTerminal(terminal, data) {
32881
33102
 
32882
33103
  // src/shared/rpc-server.ts
32883
33104
  import { randomBytes as randomBytes2 } from "node:crypto";
32884
- import { mkdirSync as mkdirSync10, renameSync as renameSync8, unlinkSync as unlinkSync7, writeFileSync as writeFileSync10 } from "node:fs";
33105
+ import {
33106
+ mkdirSync as mkdirSync10,
33107
+ readdirSync as readdirSync5,
33108
+ readFileSync as readFileSync13,
33109
+ renameSync as renameSync8,
33110
+ unlinkSync as unlinkSync7,
33111
+ writeFileSync as writeFileSync10
33112
+ } from "node:fs";
32885
33113
  import { createServer } from "node:http";
32886
33114
  import { dirname as dirname9, join as join19 } from "node:path";
32887
33115
 
@@ -32896,6 +33124,41 @@ function rpcPortFileDir(storageDir, directory) {
32896
33124
  const hash2 = projectHash(directory);
32897
33125
  return join18(storageDir, "rpc", hash2, "ports");
32898
33126
  }
33127
+ function isPidAlive(pid) {
33128
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
33129
+ return false;
33130
+ try {
33131
+ process.kill(pid, 0);
33132
+ return true;
33133
+ } catch (err) {
33134
+ return err.code === "EPERM";
33135
+ }
33136
+ }
33137
+ function parseRpcPortRecord(content) {
33138
+ const trimmed = content.trim();
33139
+ if (trimmed.length === 0)
33140
+ return null;
33141
+ if (trimmed.startsWith("{")) {
33142
+ try {
33143
+ const parsed = JSON.parse(trimmed);
33144
+ const port2 = typeof parsed.port === "number" ? parsed.port : Number.NaN;
33145
+ if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535)
33146
+ return null;
33147
+ return {
33148
+ port: port2,
33149
+ token: typeof parsed.token === "string" ? parsed.token : null,
33150
+ pid: typeof parsed.pid === "number" && Number.isInteger(parsed.pid) ? parsed.pid : undefined,
33151
+ started_at: typeof parsed.started_at === "number" ? parsed.started_at : undefined
33152
+ };
33153
+ } catch {
33154
+ return null;
33155
+ }
33156
+ }
33157
+ const port = Number.parseInt(trimmed, 10);
33158
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
33159
+ return null;
33160
+ return { port, token: null };
33161
+ }
32899
33162
 
32900
33163
  // src/shared/rpc-server.ts
32901
33164
  class AftRpcServer {
@@ -32942,6 +33205,7 @@ class AftRpcServer {
32942
33205
  }), { encoding: "utf-8", mode: 384 });
32943
33206
  renameSync8(tmpPath, this.portFilePath);
32944
33207
  log2(`RPC server listening on 127.0.0.1:${this.port}`);
33208
+ this.sweepDeadPortFiles();
32945
33209
  } catch (err) {
32946
33210
  warn2(`Failed to write RPC port file: ${err}`);
32947
33211
  }
@@ -32950,6 +33214,31 @@ class AftRpcServer {
32950
33214
  server.unref();
32951
33215
  });
32952
33216
  }
33217
+ sweepDeadPortFiles() {
33218
+ let entries;
33219
+ try {
33220
+ entries = readdirSync5(this.portsDir);
33221
+ } catch {
33222
+ return;
33223
+ }
33224
+ for (const entry of entries) {
33225
+ if (!entry.endsWith(".json"))
33226
+ continue;
33227
+ const filePath = join19(this.portsDir, entry);
33228
+ if (filePath === this.portFilePath)
33229
+ continue;
33230
+ try {
33231
+ const record2 = parseRpcPortRecord(readFileSync13(filePath, "utf-8"));
33232
+ if (record2 === null) {
33233
+ unlinkSync7(filePath);
33234
+ continue;
33235
+ }
33236
+ if (record2.pid !== undefined && !isPidAlive(record2.pid)) {
33237
+ unlinkSync7(filePath);
33238
+ }
33239
+ } catch {}
33240
+ }
33241
+ }
32953
33242
  stop() {
32954
33243
  if (this.server) {
32955
33244
  this.server.close();
@@ -33074,6 +33363,38 @@ function warmSessionDirectory(client, sessionId, fallbackDirectory) {
33074
33363
  return;
33075
33364
  getSessionDirectory(client, sessionId, fallbackDirectory);
33076
33365
  }
33366
+ var VERIFY_TTL_MS = 15000;
33367
+ var verifyCache = new Map;
33368
+ async function verifySessionDirectory(client, sessionId) {
33369
+ if (!sessionId)
33370
+ return null;
33371
+ const hit = verifyCache.get(sessionId);
33372
+ if (hit && Date.now() - hit.verifiedAt < VERIFY_TTL_MS)
33373
+ return hit.directory;
33374
+ const c = client;
33375
+ const sessionApi = c?.session;
33376
+ if (!sessionApi || typeof sessionApi.get !== "function")
33377
+ return null;
33378
+ let dir = null;
33379
+ try {
33380
+ const result = await sessionApi.get({ path: { id: sessionId } });
33381
+ const session = result?.data ?? result;
33382
+ if (session && typeof session.directory === "string" && session.directory.length > 0) {
33383
+ dir = session.directory;
33384
+ }
33385
+ } catch {
33386
+ return null;
33387
+ }
33388
+ verifyCache.set(sessionId, { directory: dir, verifiedAt: Date.now() });
33389
+ if (verifyCache.size > CACHE_MAX_ENTRIES) {
33390
+ const oldest = verifyCache.keys().next().value;
33391
+ if (oldest !== undefined)
33392
+ verifyCache.delete(oldest);
33393
+ }
33394
+ if (dir !== null)
33395
+ setCache(sessionId, dir);
33396
+ return dir;
33397
+ }
33077
33398
 
33078
33399
  // src/shared/status.ts
33079
33400
  function asRecord(value) {
@@ -33286,7 +33607,7 @@ function formatStatusMarkdown(status) {
33286
33607
 
33287
33608
  // src/shared/tui-config.ts
33288
33609
  var import_comment_json4 = __toESM(require_src2(), 1);
33289
- import { existsSync as existsSync14, mkdirSync as mkdirSync11, readFileSync as readFileSync13, writeFileSync as writeFileSync11 } from "node:fs";
33610
+ import { existsSync as existsSync14, mkdirSync as mkdirSync11, readFileSync as readFileSync14, writeFileSync as writeFileSync11 } from "node:fs";
33290
33611
  import { dirname as dirname10, join as join21 } from "node:path";
33291
33612
 
33292
33613
  // src/shared/opencode-config-dir.ts
@@ -33334,7 +33655,7 @@ function ensureTuiPluginEntry() {
33334
33655
  const configPath = resolveTuiConfigPath();
33335
33656
  let config2 = {};
33336
33657
  if (existsSync14(configPath)) {
33337
- config2 = import_comment_json4.parse(readFileSync13(configPath, "utf-8")) ?? {};
33658
+ config2 = import_comment_json4.parse(readFileSync14(configPath, "utf-8")) ?? {};
33338
33659
  }
33339
33660
  const plugins = Array.isArray(config2.plugin) ? config2.plugin.filter((value) => typeof value === "string") : [];
33340
33661
  if (plugins.some((plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) || plugin.includes("opencode-plugin") || plugin.includes("aft-opencode"))) {
@@ -33502,19 +33823,6 @@ function isEmptyParam(value) {
33502
33823
  return false;
33503
33824
  }
33504
33825
  var BASH_TRANSPORT_TIMEOUT_MS = 30000;
33505
- var LONG_RUNNING_COMMAND_TIMEOUT_MS = {
33506
- callers: 60000,
33507
- trace_to: 60000,
33508
- trace_to_symbol: 60000,
33509
- trace_data: 60000,
33510
- impact: 60000,
33511
- grep: 60000,
33512
- glob: 60000,
33513
- semantic_search: 60000
33514
- };
33515
- function timeoutForCommand(command) {
33516
- return LONG_RUNNING_COMMAND_TIMEOUT_MS[command];
33517
- }
33518
33826
  function asPlainObject(value) {
33519
33827
  if (!value || typeof value !== "object" || Array.isArray(value))
33520
33828
  return;
@@ -34091,388 +34399,61 @@ ${hint}`;
34091
34399
  };
34092
34400
  }
34093
34401
 
34094
- // src/tools/conflicts.ts
34402
+ // src/tools/bash.ts
34095
34403
  import { tool as tool4 } from "@opencode-ai/plugin";
34096
- var z4 = tool4.schema;
34097
- function conflictTools(ctx) {
34098
- return {
34099
- aft_conflicts: {
34100
- description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call. Conflicts are discovered from the git repository's top level. By default it inspects the session's project repository; pass `path` to inspect a different repository or git worktree (e.g. where a rebase/merge is running).",
34101
- args: {
34102
- path: z4.string().describe("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.").optional()
34103
- },
34104
- execute: async (args, context) => {
34105
- const params = {};
34106
- if (!isEmptyParam(args?.path))
34107
- params.path = args.path;
34108
- const response = await callBridge(ctx, context, "git_conflicts", params);
34109
- if (response.success === false) {
34110
- throw new Error(response.message || "git_conflicts failed");
34111
- }
34112
- return response.text;
34113
- }
34114
- }
34115
- };
34116
- }
34117
-
34118
- // src/tools/hoisted.ts
34119
- import * as fs6 from "node:fs";
34120
- import * as path4 from "node:path";
34121
- import { tool as tool8 } from "@opencode-ai/plugin";
34122
34404
 
34123
- // src/patch-parser.ts
34124
- function stripHeredoc(input) {
34125
- const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
34126
- return heredocMatch ? heredocMatch[2] : input;
34127
- }
34128
- function parsePatchHeader(lines, startIdx) {
34129
- const line = lines[startIdx];
34130
- if (line.startsWith("*** Add File:")) {
34131
- const filePath = line.slice("*** Add File:".length).trim();
34132
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34405
+ // src/shared/subagent-detect.ts
34406
+ var CACHE_MAX_ENTRIES2 = 200;
34407
+ var cache2 = new Map;
34408
+ async function resolveIsSubagent(client, sessionId, _fallbackDirectory) {
34409
+ if (!sessionId) {
34410
+ sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
34411
+ return false;
34133
34412
  }
34134
- if (line.startsWith("*** Delete File:")) {
34135
- const filePath = line.slice("*** Delete File:".length).trim();
34136
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34413
+ const cached2 = cache2.get(sessionId);
34414
+ if (cached2) {
34415
+ cache2.delete(sessionId);
34416
+ cache2.set(sessionId, cached2);
34417
+ return cached2.isSubagent;
34137
34418
  }
34138
- if (line.startsWith("*** Update File:")) {
34139
- const filePath = line.slice("*** Update File:".length).trim();
34140
- let movePath;
34141
- let nextIdx = startIdx + 1;
34142
- if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
34143
- movePath = lines[nextIdx].slice("*** Move to:".length).trim();
34144
- nextIdx++;
34145
- }
34146
- return filePath ? { filePath, movePath, nextIdx } : null;
34419
+ const c = client;
34420
+ const sessionApi = c?.session;
34421
+ if (!sessionApi || typeof sessionApi.get !== "function") {
34422
+ sessionLog(sessionId, `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`);
34423
+ setCache2(sessionId, false);
34424
+ return false;
34147
34425
  }
34148
- return null;
34149
- }
34150
- function parseAddFileContent(lines, startIdx) {
34151
- let content = "";
34152
- let i = startIdx;
34153
- while (i < lines.length && !lines[i].startsWith("***")) {
34154
- if (lines[i].startsWith("+")) {
34155
- content += `${lines[i].substring(1)}
34156
- `;
34157
- }
34158
- i++;
34426
+ sessionLog(sessionId, `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`);
34427
+ let isSubagent = false;
34428
+ let parentIdRaw;
34429
+ try {
34430
+ const result = await sessionApi.get({
34431
+ path: { id: sessionId }
34432
+ });
34433
+ const session = result?.data ?? result;
34434
+ parentIdRaw = session?.parentID;
34435
+ isSubagent = session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
34436
+ sessionLog(sessionId, `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`);
34437
+ } catch (err) {
34438
+ sessionWarn(sessionId, `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`);
34439
+ return false;
34159
34440
  }
34160
- if (content.endsWith(`
34161
- `)) {
34162
- content = content.slice(0, -1);
34163
- }
34164
- return { content, nextIdx: i };
34165
- }
34166
- function parseUpdateFileChunks(lines, startIdx) {
34167
- const chunks = [];
34168
- let i = startIdx;
34169
- while (i < lines.length && !lines[i].startsWith("***")) {
34170
- if (lines[i].startsWith("@@")) {
34171
- const contextLine = lines[i].substring(2).trim();
34172
- i++;
34173
- const oldLines = [];
34174
- const newLines = [];
34175
- let isEndOfFile = false;
34176
- while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
34177
- const changeLine = lines[i];
34178
- if (changeLine === "*** End of File") {
34179
- isEndOfFile = true;
34180
- i++;
34181
- break;
34182
- }
34183
- if (changeLine.startsWith(" ")) {
34184
- const content = changeLine.substring(1);
34185
- oldLines.push(content);
34186
- newLines.push(content);
34187
- } else if (changeLine.startsWith("-")) {
34188
- oldLines.push(changeLine.substring(1));
34189
- } else if (changeLine.startsWith("+")) {
34190
- newLines.push(changeLine.substring(1));
34191
- }
34192
- i++;
34193
- }
34194
- chunks.push({
34195
- old_lines: oldLines,
34196
- new_lines: newLines,
34197
- change_context: contextLine || undefined,
34198
- is_end_of_file: isEndOfFile || undefined
34199
- });
34200
- } else {
34201
- i++;
34202
- }
34203
- }
34204
- return { chunks, nextIdx: i };
34205
- }
34206
- var MAX_PATCH_SIZE = 1024 * 1024;
34207
- var MAX_HUNKS = 500;
34208
- function parsePatch(patchText) {
34209
- if (patchText.length > MAX_PATCH_SIZE) {
34210
- throw new Error(`Patch too large: ${patchText.length} bytes exceeds limit of ${MAX_PATCH_SIZE} bytes`);
34211
- }
34212
- const cleaned = stripHeredoc(patchText.trim());
34213
- const lines = cleaned.split(`
34214
- `);
34215
- const hunks = [];
34216
- const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
34217
- const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
34218
- if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
34219
- throw new Error("Invalid patch format: missing *** Begin Patch / *** End Patch markers");
34220
- }
34221
- let i = beginIdx + 1;
34222
- while (i < endIdx) {
34223
- const header = parsePatchHeader(lines, i);
34224
- if (!header) {
34225
- i++;
34226
- continue;
34227
- }
34228
- if (hunks.length >= MAX_HUNKS) {
34229
- throw new Error(`Patch exceeds maximum of ${MAX_HUNKS} file operations`);
34230
- }
34231
- if (lines[i].startsWith("*** Add File:")) {
34232
- const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
34233
- hunks.push({ type: "add", path: header.filePath, contents: content });
34234
- i = nextIdx;
34235
- } else if (lines[i].startsWith("*** Delete File:")) {
34236
- hunks.push({ type: "delete", path: header.filePath });
34237
- i = header.nextIdx;
34238
- } else if (lines[i].startsWith("*** Update File:")) {
34239
- const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
34240
- hunks.push({
34241
- type: "update",
34242
- path: header.filePath,
34243
- move_path: header.movePath,
34244
- chunks
34245
- });
34246
- i = nextIdx;
34247
- } else {
34248
- i++;
34249
- }
34250
- }
34251
- return hunks;
34252
- }
34253
- function normalizeUnicode(str) {
34254
- return str.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"').replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-").replace(/\u2026/g, "...").replace(/\u00A0/g, " ");
34255
- }
34256
- function normalizeIndent(str) {
34257
- return str.replace(/^[\t ]+/, (m) => " ".repeat(m.length));
34258
- }
34259
- function tryMatch(lines, pattern, startIndex, compare, eof) {
34260
- if (eof) {
34261
- const fromEnd = lines.length - pattern.length;
34262
- if (fromEnd >= startIndex) {
34263
- let matches = true;
34264
- for (let j = 0;j < pattern.length; j++) {
34265
- if (!compare(lines[fromEnd + j], pattern[j])) {
34266
- matches = false;
34267
- break;
34268
- }
34269
- }
34270
- if (matches)
34271
- return fromEnd;
34272
- }
34273
- }
34274
- for (let i = startIndex;i <= lines.length - pattern.length; i++) {
34275
- let matches = true;
34276
- for (let j = 0;j < pattern.length; j++) {
34277
- if (!compare(lines[i + j], pattern[j])) {
34278
- matches = false;
34279
- break;
34280
- }
34281
- }
34282
- if (matches)
34283
- return i;
34284
- }
34285
- return -1;
34286
- }
34287
- function seekSequenceTiered(lines, pattern, startIndex, eof = false) {
34288
- if (pattern.length === 0)
34289
- return null;
34290
- const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
34291
- if (exact !== -1)
34292
- return { found: exact, tier: "exact" };
34293
- const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
34294
- if (rstrip !== -1)
34295
- return { found: rstrip, tier: "rstrip" };
34296
- const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
34297
- if (trim !== -1)
34298
- return { found: trim, tier: "trim" };
34299
- const indent = tryMatch(lines, pattern, startIndex, (a, b) => normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd(), eof);
34300
- if (indent !== -1)
34301
- return { found: indent, tier: "indent" };
34302
- const unicode = tryMatch(lines, pattern, startIndex, (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), eof);
34303
- if (unicode !== -1)
34304
- return { found: unicode, tier: "unicode" };
34305
- return null;
34306
- }
34307
- function seekSequence(lines, pattern, startIndex, eof = false) {
34308
- const r = seekSequenceTiered(lines, pattern, startIndex, eof);
34309
- return r ? r.found : -1;
34310
- }
34311
- function findClosestPartialMatch(lines, pattern) {
34312
- if (pattern.length === 0 || lines.length === 0)
34313
- return null;
34314
- const compareAny = (a, b) => a === b || a.trimEnd() === b.trimEnd() || a.trim() === b.trim() || normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd() || normalizeUnicode(a.trim()) === normalizeUnicode(b.trim());
34315
- const candidates = [];
34316
- for (let i = 0;i < lines.length && candidates.length < 16; i++) {
34317
- if (compareAny(lines[i], pattern[0]))
34318
- candidates.push(i);
34319
- }
34320
- if (candidates.length === 0)
34321
- return null;
34322
- let best = { lineNumber: -1, matchedLines: 0, firstDivergence: -1 };
34323
- for (const start of candidates) {
34324
- let matched = 0;
34325
- for (let j = 0;j < pattern.length && start + j < lines.length; j++) {
34326
- if (!compareAny(lines[start + j], pattern[j]))
34327
- break;
34328
- matched++;
34329
- }
34330
- if (matched > best.matchedLines) {
34331
- best = {
34332
- lineNumber: start + 1,
34333
- matchedLines: matched,
34334
- firstDivergence: matched
34335
- };
34336
- }
34337
- }
34338
- return best.lineNumber === -1 ? null : best;
34339
- }
34340
- function applyUpdateChunks(originalContent, filePath, chunks) {
34341
- const originalLines = originalContent.split(`
34342
- `);
34343
- if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34344
- originalLines.pop();
34345
- }
34346
- const replacements = [];
34347
- let lineIndex = 0;
34348
- for (const chunk of chunks) {
34349
- if (chunk.change_context) {
34350
- const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
34351
- if (contextIdx === -1) {
34352
- throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
34353
- }
34354
- lineIndex = contextIdx + 1;
34355
- }
34356
- if (chunk.old_lines.length === 0) {
34357
- let insertionIdx;
34358
- if (chunk.change_context) {
34359
- insertionIdx = lineIndex;
34360
- } else if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34361
- insertionIdx = originalLines.length - 1;
34362
- } else {
34363
- insertionIdx = originalLines.length;
34364
- }
34365
- replacements.push([insertionIdx, 0, chunk.new_lines]);
34366
- continue;
34367
- }
34368
- let pattern = chunk.old_lines;
34369
- let newSlice = chunk.new_lines;
34370
- let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34371
- if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
34372
- pattern = pattern.slice(0, -1);
34373
- if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
34374
- newSlice = newSlice.slice(0, -1);
34375
- }
34376
- found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34377
- }
34378
- if (found !== -1) {
34379
- replacements.push([found, pattern.length, newSlice]);
34380
- lineIndex = found + pattern.length;
34381
- } else {
34382
- const newSliceTrimmed = newSlice.filter((line) => line.trim().length > 0);
34383
- const alreadyApplied = newSliceTrimmed.length > 0 && seekSequence(originalLines, newSliceTrimmed, 0, chunk.is_end_of_file) !== -1;
34384
- const closest = findClosestPartialMatch(originalLines, pattern);
34385
- let closestHint = "";
34386
- if (closest && closest.matchedLines > 0) {
34387
- const fileLineNo = closest.lineNumber + closest.firstDivergence;
34388
- const expectedLine = pattern[closest.firstDivergence];
34389
- const actualLine = fileLineNo - 1 < originalLines.length ? originalLines[fileLineNo - 1] : "<EOF>";
34390
- closestHint = `
34391
-
34392
- Closest match starts at line ${closest.lineNumber} ` + `(${closest.matchedLines} of ${pattern.length} lines matched).
34393
- ` + `First divergence at line ${fileLineNo}:
34394
- ` + ` expected: ${JSON.stringify(expectedLine)}
34395
- ` + ` actual: ${JSON.stringify(actualLine)}`;
34396
- }
34397
- const triedTiers = "exact, trimEnd, trim, indent (tab/space), unicode";
34398
- const alreadyAppliedHint = alreadyApplied ? `
34399
-
34400
- Hint: the replacement content for this hunk already appears in the file. ` + "The patch may have been partially applied in a prior turn — re-read the file " + "to confirm which hunks still need to apply." : "";
34401
- throw new Error(`Failed to find expected lines in ${filePath}:
34402
- ${chunk.old_lines.join(`
34403
- `)}
34404
-
34405
- ` + `Tried match tiers: ${triedTiers}.${closestHint}${alreadyAppliedHint}`);
34406
- }
34407
- }
34408
- replacements.sort((a, b) => a[0] - b[0]);
34409
- const result = [...originalLines];
34410
- for (let i = replacements.length - 1;i >= 0; i--) {
34411
- const [startIdx, oldLen, newSegment] = replacements[i];
34412
- result.splice(startIdx, oldLen, ...newSegment);
34413
- }
34414
- if (result.length === 0 || result[result.length - 1] !== "") {
34415
- result.push("");
34416
- }
34417
- return result.join(`
34418
- `);
34419
- }
34420
-
34421
- // src/tools/bash.ts
34422
- import { tool as tool5 } from "@opencode-ai/plugin";
34423
-
34424
- // src/shared/subagent-detect.ts
34425
- var CACHE_MAX_ENTRIES2 = 200;
34426
- var cache2 = new Map;
34427
- async function resolveIsSubagent(client, sessionId, _fallbackDirectory) {
34428
- if (!sessionId) {
34429
- sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
34430
- return false;
34431
- }
34432
- const cached2 = cache2.get(sessionId);
34433
- if (cached2) {
34434
- cache2.delete(sessionId);
34435
- cache2.set(sessionId, cached2);
34436
- return cached2.isSubagent;
34437
- }
34438
- const c = client;
34439
- const sessionApi = c?.session;
34440
- if (!sessionApi || typeof sessionApi.get !== "function") {
34441
- sessionLog(sessionId, `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`);
34442
- setCache2(sessionId, false);
34443
- return false;
34444
- }
34445
- sessionLog(sessionId, `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`);
34446
- let isSubagent = false;
34447
- let parentIdRaw;
34448
- try {
34449
- const result = await sessionApi.get({
34450
- path: { id: sessionId }
34451
- });
34452
- const session = result?.data ?? result;
34453
- parentIdRaw = session?.parentID;
34454
- isSubagent = session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
34455
- sessionLog(sessionId, `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`);
34456
- } catch (err) {
34457
- sessionWarn(sessionId, `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`);
34458
- return false;
34459
- }
34460
- setCache2(sessionId, isSubagent);
34461
- return isSubagent;
34462
- }
34463
- function setCache2(sessionId, isSubagent) {
34464
- if (cache2.has(sessionId))
34465
- cache2.delete(sessionId);
34466
- cache2.set(sessionId, { isSubagent, recordedAt: Date.now() });
34467
- if (cache2.size > CACHE_MAX_ENTRIES2) {
34468
- const oldest = cache2.keys().next().value;
34469
- if (oldest !== undefined)
34470
- cache2.delete(oldest);
34441
+ setCache2(sessionId, isSubagent);
34442
+ return isSubagent;
34443
+ }
34444
+ function setCache2(sessionId, isSubagent) {
34445
+ if (cache2.has(sessionId))
34446
+ cache2.delete(sessionId);
34447
+ cache2.set(sessionId, { isSubagent, recordedAt: Date.now() });
34448
+ if (cache2.size > CACHE_MAX_ENTRIES2) {
34449
+ const oldest = cache2.keys().next().value;
34450
+ if (oldest !== undefined)
34451
+ cache2.delete(oldest);
34471
34452
  }
34472
34453
  }
34473
34454
 
34474
34455
  // src/tools/bash.ts
34475
- var z5 = tool5.schema;
34456
+ var z4 = tool4.schema;
34476
34457
  var METADATA_PREVIEW_LIMIT = 30 * 1024;
34477
34458
  var FOREGROUND_POLL_INTERVAL_MS = 100;
34478
34459
  var DEFAULT_HARD_TIMEOUT_MS = 30 * 60 * 1000;
@@ -34485,7 +34466,14 @@ function resolveForegroundWaitMs(configured) {
34485
34466
  }
34486
34467
  return configured;
34487
34468
  }
34488
- var BASH_DESCRIPTION = `Hoisted bash tool with output compression, command rewriting to AFT tools, optional background execution, and PTY mode for interactive programs. By default, output is compressed; pass compressed: false for raw output. Pass background: true to spawn in the background and get a taskId for bash_status/bash_kill. Pass pty: true for interactive REPLs and drive them with bash_status({ outputMode: "screen" }) plus bash_write (pty implies background automatically). Use bash_watch to block on or register for pattern matches and exit events.`;
34469
+ function bashToolDescription(aftSearchRegistered, compressionOn, backgroundOn) {
34470
+ const searchSteer = aftSearchRegistered ? "use aft_search (concepts, identifiers, regex, literals), read, aft_outline, or aft_zoom instead" : "use the grep tool, read, aft_outline, or aft_zoom instead";
34471
+ const compression = compressionOn ? " Output is compressed by default; pass compressed: false for raw output." : "";
34472
+ const tasks = backgroundOn ? ' Pass background: true to run in the background and get a taskId for bash_status/bash_kill. Pass pty: true for interactive programs (REPLs, TUIs) and drive them with bash_status({ outputMode: "screen" }) plus bash_write (pty implies background automatically).' : " Commands that outlive the foreground wait window are promoted to background tasks; inspect them with bash_status({ taskId }) or terminate with bash_kill.";
34473
+ return `Execute shell commands.${compression}${tasks} Use bash_watch to wait for output patterns or exit events.
34474
+
34475
+ DO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — ${searchSteer}.`;
34476
+ }
34489
34477
  async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34490
34478
  const first = await bridgeCall(ctx, runtime, "bash", params, options);
34491
34479
  if (first.success !== false || first.code !== "permission_required")
@@ -34508,22 +34496,27 @@ async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34508
34496
  }
34509
34497
  return second;
34510
34498
  }
34511
- function createBashTool(ctx) {
34499
+ function createBashTool(ctx, aftSearchRegisteredOverride) {
34512
34500
  return {
34513
- description: BASH_DESCRIPTION,
34501
+ description: (() => {
34502
+ const cfg = resolveBashConfig(ctx.config);
34503
+ return bashToolDescription(false, cfg.compress, cfg.background);
34504
+ })(),
34514
34505
  args: {
34515
- command: z5.string().describe("Shell command to execute through AFT's unified bash schema. Supports normal shell syntax, pipes, redirection, and command rewriting to dedicated AFT tools when available."),
34506
+ command: z4.string().describe("Shell command to execute. Supports pipes, redirection, and normal shell syntax."),
34516
34507
  timeout: optionalInt(1, Number.MAX_SAFE_INTEGER).describe("Hard kill cap in milliseconds (positive integer). When omitted, the task can run up to 30 minutes. Foreground bash returns inline if the command finishes within ~8s (configurable via bash.foreground_wait_window_ms); otherwise it's automatically promoted to background and a completion reminder is delivered when the task actually finishes."),
34517
- workdir: z5.string().optional().describe("Working directory for command execution. Relative paths resolve through the bridge; defaults to the current tool context/project root when omitted."),
34518
- description: z5.string().optional().describe("Short 5-10 word human-readable summary shown in OpenCode UI metadata instead of raw shell syntax."),
34519
- background: z5.boolean().optional().describe("When true, spawn the command in the background and return a taskId for bash_status/bash_kill instead of waiting for completion. Defaults to false."),
34520
- compressed: z5.boolean().optional().describe("When true or omitted, return compressed output with noisy terminal control sequences reduced. Set to false for raw output."),
34521
- pty: z5.boolean().optional().describe('When true, spawn the command in a real PTY for interactive programs (python/node/bash REPLs, vim). Implies background: true automatically. Unavailable in subagent sessions. Inspect with bash_status({ taskId, outputMode: "screen" }) and drive interactively with bash_write — its input accepts either a string OR an array like [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ] for atomic text+key sequences.'),
34508
+ workdir: z4.string().optional().describe("Working directory for command execution. Relative paths resolve through the bridge; defaults to the current tool context/project root when omitted."),
34509
+ description: z4.string().optional().describe("Short 5-10 word human-readable summary shown in OpenCode UI metadata instead of raw shell syntax."),
34510
+ background: z4.boolean().optional().describe("When true, spawn the command in the background and return a taskId for bash_status/bash_kill instead of waiting for completion. Defaults to false."),
34511
+ compressed: z4.boolean().optional().describe("When true or omitted, return compressed output with noisy terminal control sequences reduced. Set to false for raw output."),
34512
+ pty: z4.boolean().optional().describe('When true, spawn the command in a real PTY for interactive programs (python/node/bash REPLs, vim). Implies background: true automatically. Unavailable in subagent sessions. Inspect with bash_status({ taskId, outputMode: "screen" }) and drive interactively with bash_write — its input accepts either a string OR an array like [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ] for atomic text+key sequences.'),
34522
34513
  ptyRows: optionalInt(1, 60).describe("PTY terminal height in rows — ignored when pty is false. Defaults to 24 when pty: true. Minimum 1, maximum 60."),
34523
34514
  ptyCols: optionalInt(1, 140).describe("PTY terminal width in columns — ignored when pty is false. Defaults to 80 when pty: true. Minimum 1, maximum 140.")
34524
34515
  },
34525
34516
  execute: async (args, context) => {
34526
34517
  const bashCfg = resolveBashConfig(ctx.config);
34518
+ const ctxAftSearchRegistered = ctx.aftSearchRegistered === true;
34519
+ const aftSearchRegistered = aftSearchRegisteredOverride ?? ctxAftSearchRegistered;
34527
34520
  let accumulatedOutput = "";
34528
34521
  const description = args.description;
34529
34522
  const metadata = context.metadata;
@@ -34591,7 +34584,7 @@ function createBashTool(ctx) {
34591
34584
  throw new Error(status.message ?? "bash_status failed");
34592
34585
  }
34593
34586
  if (isTerminalStatus(status.status)) {
34594
- const rendered2 = appendPipeStripNote(formatForegroundResult(status), pipeStrip.note);
34587
+ const rendered2 = maybeAppendGrepSearchHint(appendPipeStripNote(formatForegroundResult(status), pipeStrip.note), command, aftSearchRegistered);
34595
34588
  const metadataPayload2 = foregroundMetadata(description, status, rendered2);
34596
34589
  metadata?.(metadataPayload2);
34597
34590
  return { output: rendered2, title: uiTitle, metadata: metadataPayload2 };
@@ -34646,186 +34639,522 @@ function createBashTool(ctx) {
34646
34639
  rendered += `
34647
34640
  [exit code: ${exit}]`;
34648
34641
  }
34649
- rendered = appendPipeStripNote(rendered, pipeStrip.note);
34650
- return {
34651
- output: rendered,
34652
- title: description ?? shortenCommand(command),
34653
- metadata: metadataPayload
34654
- };
34642
+ rendered = appendPipeStripNote(rendered, pipeStrip.note);
34643
+ return {
34644
+ output: rendered,
34645
+ title: description ?? shortenCommand(command),
34646
+ metadata: metadataPayload
34647
+ };
34648
+ }
34649
+ };
34650
+ }
34651
+ function appendPipeStripNote(output, note) {
34652
+ return note ? `${output}
34653
+
34654
+ ${note}` : output;
34655
+ }
34656
+ function createBashStatusTool(ctx) {
34657
+ return {
34658
+ description: "Read-only snapshot of a background or PTY bash task's current state and output. Returns immediately. Never waits. Use bash_watch to block on or register for pattern matches and exit events.",
34659
+ args: {
34660
+ taskId: z4.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047."),
34661
+ outputMode: z4.enum(["screen", "raw", "both"]).optional().describe("PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.")
34662
+ },
34663
+ execute: async (args, context) => {
34664
+ const taskId = args.taskId;
34665
+ const outputMode = args.outputMode;
34666
+ const data = await bashStatusSnapshot(ctx, context, taskId, outputMode);
34667
+ return await formatBashStatusText(context, taskId, data, outputMode);
34668
+ }
34669
+ };
34670
+ }
34671
+ function createBashKillTool(ctx) {
34672
+ return {
34673
+ description: "Terminate a running background bash task spawned with bash({ background: true }). Returns confirmation of kill or an error if the task already finished.",
34674
+ args: {
34675
+ taskId: z4.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047.")
34676
+ },
34677
+ execute: async (args, context) => {
34678
+ const data = await callBashBridge(ctx, context, "bash_kill", {
34679
+ task_id: args.taskId
34680
+ });
34681
+ if (data.success === false) {
34682
+ throw new Error(data.message ?? "bash_kill failed");
34683
+ }
34684
+ await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34685
+ if (data.kill_signaled === true) {
34686
+ return `Task ${args.taskId}: kill_signaled`;
34687
+ }
34688
+ return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34689
+ }
34690
+ };
34691
+ }
34692
+ async function bashStatusSnapshot(ctx, runtime, taskId, outputMode, options) {
34693
+ const data = await callBashBridge(ctx, runtime, "bash_status", { task_id: taskId, output_mode: outputMode }, options);
34694
+ if (data.success === false) {
34695
+ throw new Error(data.message ?? "bash_status failed");
34696
+ }
34697
+ return data;
34698
+ }
34699
+ async function formatBashStatusText(runtime, taskId, data, requestedOutputMode) {
34700
+ const status = data.status;
34701
+ const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
34702
+ const dur = typeof data.duration_ms === "number" ? ` ${Math.round(data.duration_ms / 1000)}s` : "";
34703
+ let text = `Task ${taskId}: ${status}${exit}${dur}`;
34704
+ if (data.mode === "pty") {
34705
+ text += await formatPtyStatus(runtime, taskId, data, requestedOutputMode);
34706
+ } else {
34707
+ const preview = data.output_preview;
34708
+ if (preview && status !== "running") {
34709
+ text += `
34710
+ ${preview}`;
34711
+ }
34712
+ if (status === "running") {
34713
+ text += `
34714
+ A completion reminder will be delivered automatically; don't poll.`;
34715
+ }
34716
+ }
34717
+ return text;
34718
+ }
34719
+ async function formatPtyStatus(runtime, taskId, data, requestedOutputMode) {
34720
+ const outputPath = data.output_path;
34721
+ if (!outputPath)
34722
+ return `
34723
+ [PTY output path unavailable]`;
34724
+ const key = ptyCacheKey(runtime, taskId);
34725
+ const { rows, cols } = ptyDimensions(data);
34726
+ const state = await getOrCreatePtyTerminal(key, outputPath, rows, cols);
34727
+ const raw = await readPtyBytes(state);
34728
+ const outputMode = requestedOutputMode ?? "screen";
34729
+ let suffix = "";
34730
+ if (outputMode === "raw") {
34731
+ suffix = raw.length > 0 ? `
34732
+ ${raw.toString("utf8")}` : "";
34733
+ } else if (outputMode === "both") {
34734
+ suffix = `
34735
+ ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
34736
+ } else {
34737
+ const screen = renderScreen(state, rows, cols);
34738
+ suffix = screen ? `
34739
+ ${screen}` : "";
34740
+ }
34741
+ if (data.status === "running") {
34742
+ suffix += `
34743
+ PTY task is still running. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to inspect, bash_write({ taskId: "${taskId}", input: "..." }) to send keystrokes.`;
34744
+ } else if (isTerminalStatus(data.status)) {
34745
+ await disposePtyTerminal(key);
34746
+ }
34747
+ return suffix;
34748
+ }
34749
+ function ptyDimensions(data) {
34750
+ const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
34751
+ const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
34752
+ return { rows, cols };
34753
+ }
34754
+ function ptyCacheKey(runtime, taskId) {
34755
+ return `${projectRootFor(runtime)}::${runtime.sessionID ?? "__default__"}::${taskId}`;
34756
+ }
34757
+ function preview(output) {
34758
+ return output.length <= METADATA_PREVIEW_LIMIT ? output : output.slice(-METADATA_PREVIEW_LIMIT);
34759
+ }
34760
+ function isTerminalStatus(status) {
34761
+ return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
34762
+ }
34763
+ function subagentGuidance(taskId) {
34764
+ return `
34765
+
34766
+ NOTE (subagent session): Continue with other work if you have it. If you don't, call bash_watch({ taskId: "${taskId}", timeoutMs: 60000 }) to wait for completion before returning to the parent. Subagents don't survive turn-end and won't receive the completion reminder.`;
34767
+ }
34768
+ function formatBackgroundLaunch(taskId, isPty) {
34769
+ if (isPty) {
34770
+ return `PTY task started: ${taskId}. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to see the visible terminal, bash_write({ taskId: "${taskId}", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits.`;
34771
+ }
34772
+ return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
34773
+ }
34774
+ function formatPromotionMessage(taskId, timeout, waitWindowMs) {
34775
+ const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
34776
+ 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({ taskId: "${taskId}" }) to inspect output or bash_kill({ taskId: "${taskId}" }) to terminate.`;
34777
+ }
34778
+ function formatSeconds(ms) {
34779
+ return `${Number((ms / 1000).toFixed(1))}s`;
34780
+ }
34781
+ function formatForegroundResult(data) {
34782
+ const output = data.output_preview ?? "";
34783
+ const outputPath = data.output_path;
34784
+ const truncated = data.output_truncated === true;
34785
+ const status = data.status;
34786
+ const exit = data.exit_code;
34787
+ let rendered = output;
34788
+ if (truncated && outputPath) {
34789
+ rendered += `
34790
+ [output truncated; full output at ${outputPath}]`;
34791
+ }
34792
+ if (status === "timed_out") {
34793
+ rendered += `
34794
+ [command timed out]`;
34795
+ }
34796
+ if (typeof exit === "number" && exit !== 0) {
34797
+ rendered += `
34798
+ [exit code: ${exit}]`;
34799
+ }
34800
+ return rendered;
34801
+ }
34802
+ function foregroundMetadata(description, data, rendered) {
34803
+ const outputPath = data.output_path;
34804
+ return {
34805
+ description,
34806
+ output: preview(rendered),
34807
+ exit: data.exit_code,
34808
+ truncated: data.output_truncated,
34809
+ ...outputPath ? { outputPath } : {}
34810
+ };
34811
+ }
34812
+ function sleep(ms) {
34813
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
34814
+ }
34815
+ function getCallID(ctx) {
34816
+ const c = ctx;
34817
+ return c.callID ?? c.callId ?? c.call_id;
34818
+ }
34819
+ function shortenCommand(command) {
34820
+ const collapsed = command.replace(/\s+/g, " ").trim();
34821
+ return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 77)}...`;
34822
+ }
34823
+
34824
+ // src/tools/conflicts.ts
34825
+ import { tool as tool5 } from "@opencode-ai/plugin";
34826
+ var z5 = tool5.schema;
34827
+ function conflictTools(ctx) {
34828
+ return {
34829
+ aft_conflicts: {
34830
+ description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call. Conflicts are discovered from the git repository's top level. By default it inspects the session's project repository; pass `path` to inspect a different repository or git worktree (e.g. where a rebase/merge is running).",
34831
+ args: {
34832
+ path: z5.string().describe("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.").optional()
34833
+ },
34834
+ execute: async (args, context) => {
34835
+ const params = {};
34836
+ if (!isEmptyParam(args?.path)) {
34837
+ const expanded = expandTilde(String(args.path));
34838
+ const projectRoot = await resolveProjectRoot(ctx, context);
34839
+ const resolved = resolvePathFromProjectRoot(projectRoot, expanded);
34840
+ const denied = await assertExternalDirectoryPermission(context, resolved, {
34841
+ kind: "directory"
34842
+ });
34843
+ if (denied)
34844
+ return permissionDeniedResponse(denied);
34845
+ params.path = resolved;
34846
+ }
34847
+ const response = await callBridge(ctx, context, "git_conflicts", params);
34848
+ if (response.success === false) {
34849
+ throw new Error(response.message || "git_conflicts failed");
34850
+ }
34851
+ return response.text;
34852
+ }
34655
34853
  }
34656
34854
  };
34657
34855
  }
34658
- function appendPipeStripNote(output, note) {
34659
- return note ? `${output}
34660
34856
 
34661
- ${note}` : output;
34857
+ // src/tools/hoisted.ts
34858
+ import * as fs6 from "node:fs";
34859
+ import * as path4 from "node:path";
34860
+ import { tool as tool8 } from "@opencode-ai/plugin";
34861
+
34862
+ // src/patch-parser.ts
34863
+ function stripHeredoc(input) {
34864
+ const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
34865
+ return heredocMatch ? heredocMatch[2] : input;
34662
34866
  }
34663
- function createBashStatusTool(ctx) {
34664
- return {
34665
- description: "Read-only snapshot of a background or PTY bash task's current state and output. Returns immediately. Never waits. Use bash_watch to block on or register for pattern matches and exit events.",
34666
- args: {
34667
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047."),
34668
- outputMode: z5.enum(["screen", "raw", "both"]).optional().describe("PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.")
34669
- },
34670
- execute: async (args, context) => {
34671
- const taskId = args.taskId;
34672
- const outputMode = args.outputMode;
34673
- const data = await bashStatusSnapshot(ctx, context, taskId, outputMode);
34674
- return await formatBashStatusText(context, taskId, data, outputMode);
34867
+ function parsePatchHeader(lines, startIdx) {
34868
+ const line = lines[startIdx];
34869
+ if (line.startsWith("*** Add File:")) {
34870
+ const filePath = line.slice("*** Add File:".length).trim();
34871
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34872
+ }
34873
+ if (line.startsWith("*** Delete File:")) {
34874
+ const filePath = line.slice("*** Delete File:".length).trim();
34875
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34876
+ }
34877
+ if (line.startsWith("*** Update File:")) {
34878
+ const filePath = line.slice("*** Update File:".length).trim();
34879
+ let movePath;
34880
+ let nextIdx = startIdx + 1;
34881
+ if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
34882
+ movePath = lines[nextIdx].slice("*** Move to:".length).trim();
34883
+ nextIdx++;
34675
34884
  }
34676
- };
34885
+ return filePath ? { filePath, movePath, nextIdx } : null;
34886
+ }
34887
+ return null;
34677
34888
  }
34678
- function createBashKillTool(ctx) {
34679
- return {
34680
- description: "Terminate a running background bash task spawned with bash({ background: true }). Returns confirmation of kill or an error if the task already finished.",
34681
- args: {
34682
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047.")
34683
- },
34684
- execute: async (args, context) => {
34685
- const data = await callBashBridge(ctx, context, "bash_kill", {
34686
- task_id: args.taskId
34889
+ function parseAddFileContent(lines, startIdx) {
34890
+ let content = "";
34891
+ let i = startIdx;
34892
+ while (i < lines.length && !lines[i].startsWith("***")) {
34893
+ if (lines[i].startsWith("+")) {
34894
+ content += `${lines[i].substring(1)}
34895
+ `;
34896
+ }
34897
+ i++;
34898
+ }
34899
+ if (content.endsWith(`
34900
+ `)) {
34901
+ content = content.slice(0, -1);
34902
+ }
34903
+ return { content, nextIdx: i };
34904
+ }
34905
+ function parseUpdateFileChunks(lines, startIdx) {
34906
+ const chunks = [];
34907
+ let i = startIdx;
34908
+ while (i < lines.length && !lines[i].startsWith("***")) {
34909
+ if (lines[i].startsWith("@@")) {
34910
+ const contextLine = lines[i].substring(2).trim();
34911
+ i++;
34912
+ const oldLines = [];
34913
+ const newLines = [];
34914
+ let isEndOfFile = false;
34915
+ while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
34916
+ const changeLine = lines[i];
34917
+ if (changeLine === "*** End of File") {
34918
+ isEndOfFile = true;
34919
+ i++;
34920
+ break;
34921
+ }
34922
+ if (changeLine.startsWith(" ")) {
34923
+ const content = changeLine.substring(1);
34924
+ oldLines.push(content);
34925
+ newLines.push(content);
34926
+ } else if (changeLine.startsWith("-")) {
34927
+ oldLines.push(changeLine.substring(1));
34928
+ } else if (changeLine.startsWith("+")) {
34929
+ newLines.push(changeLine.substring(1));
34930
+ }
34931
+ i++;
34932
+ }
34933
+ chunks.push({
34934
+ old_lines: oldLines,
34935
+ new_lines: newLines,
34936
+ change_context: contextLine || undefined,
34937
+ is_end_of_file: isEndOfFile || undefined
34687
34938
  });
34688
- if (data.success === false) {
34689
- throw new Error(data.message ?? "bash_kill failed");
34939
+ } else {
34940
+ i++;
34941
+ }
34942
+ }
34943
+ return { chunks, nextIdx: i };
34944
+ }
34945
+ var MAX_PATCH_SIZE = 1024 * 1024;
34946
+ var MAX_HUNKS = 500;
34947
+ function parsePatch(patchText) {
34948
+ if (patchText.length > MAX_PATCH_SIZE) {
34949
+ throw new Error(`Patch too large: ${patchText.length} bytes exceeds limit of ${MAX_PATCH_SIZE} bytes`);
34950
+ }
34951
+ const cleaned = stripHeredoc(patchText.trim());
34952
+ const lines = cleaned.split(`
34953
+ `);
34954
+ const hunks = [];
34955
+ const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
34956
+ const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
34957
+ if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
34958
+ throw new Error("Invalid patch format: missing *** Begin Patch / *** End Patch markers");
34959
+ }
34960
+ let i = beginIdx + 1;
34961
+ while (i < endIdx) {
34962
+ const header = parsePatchHeader(lines, i);
34963
+ if (!header) {
34964
+ i++;
34965
+ continue;
34966
+ }
34967
+ if (hunks.length >= MAX_HUNKS) {
34968
+ throw new Error(`Patch exceeds maximum of ${MAX_HUNKS} file operations`);
34969
+ }
34970
+ if (lines[i].startsWith("*** Add File:")) {
34971
+ const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
34972
+ hunks.push({ type: "add", path: header.filePath, contents: content });
34973
+ i = nextIdx;
34974
+ } else if (lines[i].startsWith("*** Delete File:")) {
34975
+ hunks.push({ type: "delete", path: header.filePath });
34976
+ i = header.nextIdx;
34977
+ } else if (lines[i].startsWith("*** Update File:")) {
34978
+ const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
34979
+ hunks.push({
34980
+ type: "update",
34981
+ path: header.filePath,
34982
+ move_path: header.movePath,
34983
+ chunks
34984
+ });
34985
+ i = nextIdx;
34986
+ } else {
34987
+ i++;
34988
+ }
34989
+ }
34990
+ return hunks;
34991
+ }
34992
+ function normalizeUnicode(str) {
34993
+ return str.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"').replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-").replace(/\u2026/g, "...").replace(/\u00A0/g, " ");
34994
+ }
34995
+ function normalizeIndent(str) {
34996
+ return str.replace(/^[\t ]+/, (m) => " ".repeat(m.length));
34997
+ }
34998
+ function tryMatch(lines, pattern, startIndex, compare, eof) {
34999
+ if (eof) {
35000
+ const fromEnd = lines.length - pattern.length;
35001
+ if (fromEnd >= startIndex) {
35002
+ let matches = true;
35003
+ for (let j = 0;j < pattern.length; j++) {
35004
+ if (!compare(lines[fromEnd + j], pattern[j])) {
35005
+ matches = false;
35006
+ break;
35007
+ }
34690
35008
  }
34691
- await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34692
- if (data.kill_signaled === true) {
34693
- return `Task ${args.taskId}: kill_signaled`;
35009
+ if (matches)
35010
+ return fromEnd;
35011
+ }
35012
+ }
35013
+ for (let i = startIndex;i <= lines.length - pattern.length; i++) {
35014
+ let matches = true;
35015
+ for (let j = 0;j < pattern.length; j++) {
35016
+ if (!compare(lines[i + j], pattern[j])) {
35017
+ matches = false;
35018
+ break;
34694
35019
  }
34695
- return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34696
35020
  }
34697
- };
34698
- }
34699
- async function bashStatusSnapshot(ctx, runtime, taskId, outputMode, options) {
34700
- const data = await callBashBridge(ctx, runtime, "bash_status", { task_id: taskId, output_mode: outputMode }, options);
34701
- if (data.success === false) {
34702
- throw new Error(data.message ?? "bash_status failed");
35021
+ if (matches)
35022
+ return i;
34703
35023
  }
34704
- return data;
35024
+ return -1;
34705
35025
  }
34706
- async function formatBashStatusText(runtime, taskId, data, requestedOutputMode) {
34707
- const status = data.status;
34708
- const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
34709
- const dur = typeof data.duration_ms === "number" ? ` ${Math.round(data.duration_ms / 1000)}s` : "";
34710
- let text = `Task ${taskId}: ${status}${exit}${dur}`;
34711
- if (data.mode === "pty") {
34712
- text += await formatPtyStatus(runtime, taskId, data, requestedOutputMode);
34713
- } else {
34714
- const preview = data.output_preview;
34715
- if (preview && status !== "running") {
34716
- text += `
34717
- ${preview}`;
35026
+ function seekSequenceTiered(lines, pattern, startIndex, eof = false) {
35027
+ if (pattern.length === 0)
35028
+ return null;
35029
+ const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
35030
+ if (exact !== -1)
35031
+ return { found: exact, tier: "exact" };
35032
+ const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
35033
+ if (rstrip !== -1)
35034
+ return { found: rstrip, tier: "rstrip" };
35035
+ const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
35036
+ if (trim !== -1)
35037
+ return { found: trim, tier: "trim" };
35038
+ const indent = tryMatch(lines, pattern, startIndex, (a, b) => normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd(), eof);
35039
+ if (indent !== -1)
35040
+ return { found: indent, tier: "indent" };
35041
+ const unicode = tryMatch(lines, pattern, startIndex, (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), eof);
35042
+ if (unicode !== -1)
35043
+ return { found: unicode, tier: "unicode" };
35044
+ return null;
35045
+ }
35046
+ function seekSequence(lines, pattern, startIndex, eof = false) {
35047
+ const r = seekSequenceTiered(lines, pattern, startIndex, eof);
35048
+ return r ? r.found : -1;
35049
+ }
35050
+ function findClosestPartialMatch(lines, pattern) {
35051
+ if (pattern.length === 0 || lines.length === 0)
35052
+ return null;
35053
+ const compareAny = (a, b) => a === b || a.trimEnd() === b.trimEnd() || a.trim() === b.trim() || normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd() || normalizeUnicode(a.trim()) === normalizeUnicode(b.trim());
35054
+ const candidates = [];
35055
+ for (let i = 0;i < lines.length && candidates.length < 16; i++) {
35056
+ if (compareAny(lines[i], pattern[0]))
35057
+ candidates.push(i);
35058
+ }
35059
+ if (candidates.length === 0)
35060
+ return null;
35061
+ let best = { lineNumber: -1, matchedLines: 0, firstDivergence: -1 };
35062
+ for (const start of candidates) {
35063
+ let matched = 0;
35064
+ for (let j = 0;j < pattern.length && start + j < lines.length; j++) {
35065
+ if (!compareAny(lines[start + j], pattern[j]))
35066
+ break;
35067
+ matched++;
34718
35068
  }
34719
- if (status === "running") {
34720
- text += `
34721
- A completion reminder will be delivered automatically; don't poll.`;
35069
+ if (matched > best.matchedLines) {
35070
+ best = {
35071
+ lineNumber: start + 1,
35072
+ matchedLines: matched,
35073
+ firstDivergence: matched
35074
+ };
34722
35075
  }
34723
35076
  }
34724
- return text;
35077
+ return best.lineNumber === -1 ? null : best;
34725
35078
  }
34726
- async function formatPtyStatus(runtime, taskId, data, requestedOutputMode) {
34727
- const outputPath = data.output_path;
34728
- if (!outputPath)
34729
- return `
34730
- [PTY output path unavailable]`;
34731
- const key = ptyCacheKey(runtime, taskId);
34732
- const { rows, cols } = ptyDimensions(data);
34733
- const state = await getOrCreatePtyTerminal(key, outputPath, rows, cols);
34734
- const raw = await readPtyBytes(state);
34735
- const outputMode = requestedOutputMode ?? "screen";
34736
- let suffix = "";
34737
- if (outputMode === "raw") {
34738
- suffix = raw.length > 0 ? `
34739
- ${raw.toString("utf8")}` : "";
34740
- } else if (outputMode === "both") {
34741
- suffix = `
34742
- ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
34743
- } else {
34744
- const screen = renderScreen(state, rows, cols);
34745
- suffix = screen ? `
34746
- ${screen}` : "";
34747
- }
34748
- if (data.status === "running") {
34749
- suffix += `
34750
- PTY task is still running. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to inspect, bash_write({ taskId: "${taskId}", input: "..." }) to send keystrokes.`;
34751
- } else if (isTerminalStatus(data.status)) {
34752
- await disposePtyTerminal(key);
35079
+ function applyUpdateChunks(originalContent, filePath, chunks) {
35080
+ const originalLines = originalContent.split(`
35081
+ `);
35082
+ if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
35083
+ originalLines.pop();
34753
35084
  }
34754
- return suffix;
34755
- }
34756
- function ptyDimensions(data) {
34757
- const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
34758
- const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
34759
- return { rows, cols };
34760
- }
34761
- function ptyCacheKey(runtime, taskId) {
34762
- return `${projectRootFor(runtime)}::${runtime.sessionID ?? "__default__"}::${taskId}`;
34763
- }
34764
- function preview(output) {
34765
- return output.length <= METADATA_PREVIEW_LIMIT ? output : output.slice(-METADATA_PREVIEW_LIMIT);
34766
- }
34767
- function isTerminalStatus(status) {
34768
- return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
34769
- }
34770
- function subagentGuidance(taskId) {
34771
- return `
35085
+ const replacements = [];
35086
+ let lineIndex = 0;
35087
+ for (const chunk of chunks) {
35088
+ if (chunk.change_context) {
35089
+ const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
35090
+ if (contextIdx === -1) {
35091
+ throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
35092
+ }
35093
+ lineIndex = contextIdx + 1;
35094
+ }
35095
+ if (chunk.old_lines.length === 0) {
35096
+ let insertionIdx;
35097
+ if (chunk.change_context) {
35098
+ insertionIdx = lineIndex;
35099
+ } else if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
35100
+ insertionIdx = originalLines.length - 1;
35101
+ } else {
35102
+ insertionIdx = originalLines.length;
35103
+ }
35104
+ replacements.push([insertionIdx, 0, chunk.new_lines]);
35105
+ continue;
35106
+ }
35107
+ let pattern = chunk.old_lines;
35108
+ let newSlice = chunk.new_lines;
35109
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
35110
+ if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
35111
+ pattern = pattern.slice(0, -1);
35112
+ if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
35113
+ newSlice = newSlice.slice(0, -1);
35114
+ }
35115
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
35116
+ }
35117
+ if (found !== -1) {
35118
+ replacements.push([found, pattern.length, newSlice]);
35119
+ lineIndex = found + pattern.length;
35120
+ } else {
35121
+ const newSliceTrimmed = newSlice.filter((line) => line.trim().length > 0);
35122
+ const alreadyApplied = newSliceTrimmed.length > 0 && seekSequence(originalLines, newSliceTrimmed, 0, chunk.is_end_of_file) !== -1;
35123
+ const closest = findClosestPartialMatch(originalLines, pattern);
35124
+ let closestHint = "";
35125
+ if (closest && closest.matchedLines > 0) {
35126
+ const fileLineNo = closest.lineNumber + closest.firstDivergence;
35127
+ const expectedLine = pattern[closest.firstDivergence];
35128
+ const actualLine = fileLineNo - 1 < originalLines.length ? originalLines[fileLineNo - 1] : "<EOF>";
35129
+ closestHint = `
34772
35130
 
34773
- NOTE (subagent session): Continue with other work if you have it. If you don't, call bash_watch({ taskId: "${taskId}", timeoutMs: 60000 }) to wait for completion before returning to the parent. Subagents don't survive turn-end and won't receive the completion reminder.`;
34774
- }
34775
- function formatBackgroundLaunch(taskId, isPty) {
34776
- if (isPty) {
34777
- return `PTY task started: ${taskId}. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to see the visible terminal, bash_write({ taskId: "${taskId}", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits.`;
34778
- }
34779
- return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
34780
- }
34781
- function formatPromotionMessage(taskId, timeout, waitWindowMs) {
34782
- const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
34783
- 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({ taskId: "${taskId}" }) to inspect output or bash_kill({ taskId: "${taskId}" }) to terminate.`;
34784
- }
34785
- function formatSeconds(ms) {
34786
- return `${Number((ms / 1000).toFixed(1))}s`;
34787
- }
34788
- function formatForegroundResult(data) {
34789
- const output = data.output_preview ?? "";
34790
- const outputPath = data.output_path;
34791
- const truncated = data.output_truncated === true;
34792
- const status = data.status;
34793
- const exit = data.exit_code;
34794
- let rendered = output;
34795
- if (truncated && outputPath) {
34796
- rendered += `
34797
- [output truncated; full output at ${outputPath}]`;
35131
+ Closest match starts at line ${closest.lineNumber} ` + `(${closest.matchedLines} of ${pattern.length} lines matched).
35132
+ ` + `First divergence at line ${fileLineNo}:
35133
+ ` + ` expected: ${JSON.stringify(expectedLine)}
35134
+ ` + ` actual: ${JSON.stringify(actualLine)}`;
35135
+ }
35136
+ const triedTiers = "exact, trimEnd, trim, indent (tab/space), unicode";
35137
+ const alreadyAppliedHint = alreadyApplied ? `
35138
+
35139
+ Hint: the replacement content for this hunk already appears in the file. ` + "The patch may have been partially applied in a prior turn — re-read the file " + "to confirm which hunks still need to apply." : "";
35140
+ throw new Error(`Failed to find expected lines in ${filePath}:
35141
+ ${chunk.old_lines.join(`
35142
+ `)}
35143
+
35144
+ ` + `Tried match tiers: ${triedTiers}.${closestHint}${alreadyAppliedHint}`);
35145
+ }
34798
35146
  }
34799
- if (status === "timed_out") {
34800
- rendered += `
34801
- [command timed out]`;
35147
+ replacements.sort((a, b) => a[0] - b[0]);
35148
+ const result = [...originalLines];
35149
+ for (let i = replacements.length - 1;i >= 0; i--) {
35150
+ const [startIdx, oldLen, newSegment] = replacements[i];
35151
+ result.splice(startIdx, oldLen, ...newSegment);
34802
35152
  }
34803
- if (typeof exit === "number" && exit !== 0) {
34804
- rendered += `
34805
- [exit code: ${exit}]`;
35153
+ if (result.length === 0 || result[result.length - 1] !== "") {
35154
+ result.push("");
34806
35155
  }
34807
- return rendered;
34808
- }
34809
- function foregroundMetadata(description, data, rendered) {
34810
- const outputPath = data.output_path;
34811
- return {
34812
- description,
34813
- output: preview(rendered),
34814
- exit: data.exit_code,
34815
- truncated: data.output_truncated,
34816
- ...outputPath ? { outputPath } : {}
34817
- };
34818
- }
34819
- function sleep(ms) {
34820
- return new Promise((resolve7) => setTimeout(resolve7, ms));
34821
- }
34822
- function getCallID(ctx) {
34823
- const c = ctx;
34824
- return c.callID ?? c.callId ?? c.call_id;
34825
- }
34826
- function shortenCommand(command) {
34827
- const collapsed = command.replace(/\s+/g, " ").trim();
34828
- return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 77)}...`;
35156
+ return result.join(`
35157
+ `);
34829
35158
  }
34830
35159
 
34831
35160
  // src/tools/bash_watch.ts
@@ -35339,7 +35668,6 @@ function inferBeforeStart(ops, from, beforeLen) {
35339
35668
  return beforeLen;
35340
35669
  }
35341
35670
  var z8 = tool8.schema;
35342
- 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.";
35343
35671
  function diagnosticsOnEditDefault(ctx) {
35344
35672
  return ctx.config.lsp?.diagnostics_on_edit ?? false;
35345
35673
  }
@@ -35471,30 +35799,14 @@ function createReadTool(ctx) {
35471
35799
  };
35472
35800
  }
35473
35801
  function getWriteDescription(editToolName) {
35474
- return `Write content to a file, creating it (and parent directories) if needed.
35475
-
35476
- Automatically creates parent directories. Backs up existing files before overwriting.
35477
- If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
35478
- is auto-formatted after writing. Edits return as soon as the write completes unless
35479
- the configured \`lsp.diagnostics_on_edit\` default or a per-call \`diagnostics: true\`
35480
- asks for legacy sync-wait behavior. Call \`aft_inspect\` afterward to check
35481
- diagnostics across a batch of edits.
35482
-
35483
- **Behavior:**
35484
- - Creates parent directories automatically (no need to mkdir first)
35485
- - Existing files are backed up before overwriting (recoverable via aft_safety undo)
35486
- - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
35487
- - LSP diagnostics follow \`lsp.diagnostics_on_edit\` by default; pass \`diagnostics\` to override per call
35488
- - Use this for creating new files or completely replacing file contents
35489
- - For partial edits (find/replace), use the \`${editToolName}\` tool instead`;
35802
+ return `Write content to a file, creating it and parent directories automatically. Existing files are backed up before overwriting (undo via aft_safety) and auto-formatted when the project has a formatter configured. Use it to create files or replace whole contents; for partial edits, use the \`${editToolName}\` tool.`;
35490
35803
  }
35491
35804
  function createWriteTool(ctx, editToolName = "edit") {
35492
35805
  return {
35493
35806
  description: getWriteDescription(editToolName),
35494
35807
  args: {
35495
35808
  filePath: z8.string().describe("Path to the file to write (absolute or relative to project root)"),
35496
- content: z8.string().describe("The full content to write to the file"),
35497
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
35809
+ content: z8.string().describe("The full content to write to the file")
35498
35810
  },
35499
35811
  execute: async (args, context) => {
35500
35812
  const file2 = args.filePath;
@@ -35517,12 +35829,15 @@ function createWriteTool(ctx, editToolName = "edit") {
35517
35829
  file: filePath,
35518
35830
  content,
35519
35831
  create_dirs: true,
35520
- diagnostics: args.diagnostics ?? diagnosticsOnEditDefault(ctx),
35832
+ diagnostics: diagnosticsOnEditDefault(ctx),
35521
35833
  include_diff_content: true
35522
35834
  });
35523
35835
  if (data.success === false) {
35524
35836
  throw new Error(data.message || "write failed");
35525
35837
  }
35838
+ if (data.rolled_back === true) {
35839
+ return "Write rolled back: the content produced invalid syntax, so the file was left unchanged.";
35840
+ }
35526
35841
  let output = data.created ? "Created new file." : "File updated.";
35527
35842
  if (data.formatted)
35528
35843
  output += " Auto-formatted.";
@@ -35621,7 +35936,6 @@ Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldS
35621
35936
  - Auto-formats using project formatter if configured
35622
35937
  - Tree-sitter syntax validation on all edits
35623
35938
  - Symbol replace includes decorators, attributes, and doc comments in range
35624
- - 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.
35625
35939
  - Response is a JSON string for the selected edit mode; key fields include success, diff, backup_id, syntax_valid, and mode-specific fields.`;
35626
35940
  }
35627
35941
  function createEditTool(ctx, writeToolName = "write") {
@@ -35636,8 +35950,7 @@ function createEditTool(ctx, writeToolName = "write") {
35636
35950
  symbol: z8.string().optional().describe("Named symbol to replace (function, class, type)"),
35637
35951
  content: z8.string().optional().describe("Replacement content for symbol mode. For whole-file writes, use the `write` tool."),
35638
35952
  appendContent: z8.string().optional().describe("Text to append to the end of filePath; creates the file if needed"),
35639
- edits: z8.array(z8.record(z8.string(), z8.unknown())).optional().describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }"),
35640
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
35953
+ edits: z8.array(z8.record(z8.string(), z8.unknown())).optional().describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }")
35641
35954
  },
35642
35955
  execute: async (args, context) => {
35643
35956
  const argsRecord = args;
@@ -35703,7 +36016,7 @@ function createEditTool(ctx, writeToolName = "write") {
35703
36016
  const hint = typeof args.content === "string" ? ` To write the whole file, use the '${writeToolName}' tool. To edit existing content, provide 'oldString' (and optionally 'newString'), 'symbol' + 'content', or an 'edits' array.` : " Provide 'oldString' (+ optional 'newString'), 'symbol' + 'content', or an 'edits' array.";
35704
36017
  throw new Error(`edit: no edit mode resolved from arguments.${hint}`);
35705
36018
  }
35706
- params.diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36019
+ params.diagnostics = diagnosticsOnEditDefault(ctx);
35707
36020
  params.include_diff_content = true;
35708
36021
  const data = await callBridge(ctx, context, command, params);
35709
36022
  if (data.success === false) {
@@ -35825,12 +36138,11 @@ function createApplyPatchTool(ctx) {
35825
36138
  return {
35826
36139
  description: APPLY_PATCH_DESCRIPTION,
35827
36140
  args: {
35828
- patchText: z8.string().describe("The full patch text including Begin/End markers"),
35829
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
36141
+ patchText: z8.string().describe("The full patch text including Begin/End markers")
35830
36142
  },
35831
36143
  execute: async (args, context) => {
35832
36144
  const patchText = args.patchText;
35833
- const diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36145
+ const diagnostics = diagnosticsOnEditDefault(ctx);
35834
36146
  if (!patchText)
35835
36147
  throw new Error("'patchText' is required");
35836
36148
  let hunks;
@@ -35918,6 +36230,9 @@ function createApplyPatchTool(ctx) {
35918
36230
  if (writeResult.success === false) {
35919
36231
  throw new Error(writeResult.message ?? "write failed");
35920
36232
  }
36233
+ if (writeResult.rolled_back === true) {
36234
+ throw new Error("produced invalid syntax (rolled back)");
36235
+ }
35921
36236
  const wrDiff = writeResult.diff;
35922
36237
  perFileDiffs.push({
35923
36238
  filePath,
@@ -35979,6 +36294,9 @@ function createApplyPatchTool(ctx) {
35979
36294
  if (writeResult.success === false) {
35980
36295
  throw new Error(writeResult.message ?? "write failed");
35981
36296
  }
36297
+ if (writeResult.rolled_back === true) {
36298
+ throw new Error("produced invalid syntax (rolled back)");
36299
+ }
35982
36300
  const diags = writeResult.lsp_diagnostics;
35983
36301
  if (diags && diags.length > 0) {
35984
36302
  const errors3 = diags.filter((d) => d.severity === "error");
@@ -37049,17 +37367,12 @@ var z13 = tool13.schema;
37049
37367
  function refactoringTools(ctx) {
37050
37368
  return {
37051
37369
  aft_refactor: {
37052
- description: `Workspace-wide refactoring operations that update imports and references across files.
37370
+ description: `Workspace-wide refactoring that updates imports and references across files.
37053
37371
 
37054
37372
  ` + `Ops:
37055
- ` + `- 'move': Move a top-level symbol to another file, updating all imports workspace-wide. Requires 'symbol', 'destination'. Creates a checkpoint before mutating. Only works on top-level exports (not nested functions or class methods).
37056
- ` + ` Note: This moves code symbols between files. To rename/move an entire file, use aft_move instead.
37057
- ` + `- 'extract': Extract a line range into a new function with auto-detected parameters. Requires 'name', 'startLine', 'endLine' (1-based, both inclusive). Supports TS/JS/TSX and Python.
37058
- ` + `- 'inline': Replace a function call with the function's body, substituting args for params. Requires 'symbol', 'callSiteLine' (1-based). Validates single-return constraint.
37059
-
37060
- ` + `Each op requires specific parameters — see parameter descriptions for requirements.
37061
-
37062
- ` + "All ops need 'filePath'. Use aft_safety checkpoint/undo before risky refactors.",
37373
+ ` + `- 'move': move a top-level symbol (not nested functions or class methods) to another file, rewriting imports workspace-wide. A checkpoint is created first. To move/rename a whole file, use aft_move.
37374
+ ` + `- 'extract': extract a line range into a new function with auto-detected parameters (TS/JS/TSX, Python).
37375
+ ` + "- 'inline': replace a function call with the function's body.",
37063
37376
  args: {
37064
37377
  op: z13.enum(["move", "extract", "inline"]).describe("Refactoring operation"),
37065
37378
  filePath: z13.string().describe("Path to the source file (absolute or relative to project root)"),
@@ -37228,9 +37541,10 @@ function safetyTools(ctx) {
37228
37541
  if (Array.isArray(checkpointFiles)) {
37229
37542
  const projectRoot = await resolveProjectRoot(ctx, context);
37230
37543
  const uniqueParents = new Set;
37231
- for (const file2 of checkpointFiles) {
37232
- if (typeof file2 !== "string")
37544
+ for (const rawFile of checkpointFiles) {
37545
+ if (typeof rawFile !== "string")
37233
37546
  continue;
37547
+ const file2 = expandTilde(rawFile);
37234
37548
  const abs = path5.isAbsolute(file2) ? file2 : path5.resolve(projectRoot, file2);
37235
37549
  const parent = path5.dirname(abs);
37236
37550
  if (uniqueParents.has(parent))
@@ -37270,16 +37584,17 @@ function safetyTools(ctx) {
37270
37584
  const params = {};
37271
37585
  if (args.name !== undefined)
37272
37586
  params.name = args.name;
37273
- const payloadFiles = coerceStringArray(args.files);
37587
+ const payloadFiles = coerceStringArray(args.files).map(expandTilde);
37588
+ const filePathArg = typeof args.filePath === "string" ? expandTilde(args.filePath) : undefined;
37274
37589
  if (op === "checkpoint") {
37275
37590
  if (payloadFiles.length > 0) {
37276
37591
  params.files = payloadFiles;
37277
- } else if (args.filePath !== undefined) {
37278
- params.files = [args.filePath];
37592
+ } else if (filePathArg !== undefined) {
37593
+ params.files = [filePathArg];
37279
37594
  }
37280
37595
  } else {
37281
- if (args.filePath !== undefined)
37282
- params.file = args.filePath;
37596
+ if (filePathArg !== undefined)
37597
+ params.file = filePathArg;
37283
37598
  if (payloadFiles.length > 0)
37284
37599
  params.files = payloadFiles;
37285
37600
  }
@@ -37523,21 +37838,9 @@ function arg3(schema) {
37523
37838
  function semanticTools(ctx) {
37524
37839
  const searchTool = {
37525
37840
  description: [
37526
- "Find code with unified semantic, lexical, literal, and regex search. Returns ranked symbol/file results or exact matching lines, with routing metadata.",
37527
- "",
37528
- "When to reach for it:",
37529
- "- Exploring an unfamiliar area: 'where is rate limiting handled', 'how does auth flow work'",
37530
- "- Concept doesn't appear as a literal string: 'retry logic', 'cache invalidation', 'graceful shutdown'",
37531
- "- Filename-shaped concepts: 'the bridge spawn helper', 'the session detection module'",
37532
- "- Regex-shaped or exact text queries when you want AFT to classify and route automatically",
37533
- "- You know roughly what the function does but not what it's named",
37534
- "",
37535
- "When NOT to use:",
37536
- "- You need exhaustive literal enumeration → use grep directly",
37537
- "- You want the file/module structure → use aft_outline",
37538
- "- You're following a call chain → use aft_callgraph",
37841
+ "Search code with one tool: concepts, identifiers, error strings, regex, literals, and filenames are auto-routed to the right engine and returned ranked. Use it for any code search — including when you only know what the code does, not what it's named ('where is rate limiting handled', 'retry logic', '^export', 'Cargo.lock').",
37539
37842
  "",
37540
- "Set hint to 'regex', 'literal', 'semantic', or 'auto' to override or document routing intent."
37843
+ "Set hint to 'regex', 'literal', or 'semantic' to force a lane."
37541
37844
  ].join(`
37542
37845
  `),
37543
37846
  args: {
@@ -37581,118 +37884,8 @@ ${note}` : response.text;
37581
37884
  };
37582
37885
  }
37583
37886
 
37584
- // src/tools/structure.ts
37585
- import { tool as tool16 } from "@opencode-ai/plugin";
37586
- var z16 = tool16.schema;
37587
- function structureTools(ctx) {
37588
- return {
37589
- aft_transform: {
37590
- description: `Scope-aware structural code transformations with correct indentation.
37591
-
37592
- ` + `Language-specific: add_derive (Rust), add_struct_tags (Go), add_decorator (Python), wrap_try_catch (TS/JS), add_member (any language).
37593
-
37594
- ` + `Ops:
37595
- ` + `- 'add_member': Insert method/field into class, struct, or impl block. Requires 'container' (container name) and 'code'. Optional 'position'.
37596
- ` + `- 'add_derive': Add Rust derive macros to a struct/enum. Requires 'target' and 'derives' array. Deduplicates existing derives.
37597
- ` + `- 'wrap_try_catch': Wrap a TS/JS function body in try/catch. Requires 'target' (function name). Optional 'catchBody'.
37598
- ` + `- 'add_decorator': Add Python decorator to function/class. Requires 'target' and 'decorator' (without @). Optional 'position'.
37599
- ` + `- 'add_struct_tags': Add/update Go struct field tags. Requires 'target' (struct name), 'field', 'tag', 'value'.
37600
-
37601
- ` + "Each op requires specific parameters — see parameter descriptions for requirements. Use aft_safety checkpoint/undo before risky transforms.",
37602
- args: {
37603
- op: z16.enum(["add_member", "add_derive", "wrap_try_catch", "add_decorator", "add_struct_tags"]).describe("Transformation operation"),
37604
- filePath: z16.string().describe("Path to the source file (absolute or relative to project root)"),
37605
- container: z16.string().optional().describe("Container name for add_member — the class, struct, or impl block to insert into. Appears as 'scope' in the response."),
37606
- code: z16.string().optional().describe("Member code to insert (add_member)"),
37607
- position: z16.string().optional().describe("For add_member: 'first', 'last' (default), 'before:name', 'after:name'. For add_decorator: 'first' (default) or 'last' only."),
37608
- target: z16.string().optional().describe("Target symbol name (add_derive: struct/enum, wrap_try_catch: function, add_decorator: function/class, add_struct_tags: struct)"),
37609
- derives: z16.array(z16.string()).optional().describe("Derive macro names (add_derive — e.g. ['Clone', 'Debug'])"),
37610
- catchBody: z16.string().optional().describe("Catch block body (wrap_try_catch — default: 'throw error;')"),
37611
- decorator: z16.string().optional().describe("Decorator text without @ (add_decorator — e.g. 'staticmethod')"),
37612
- field: z16.string().optional().describe("Struct field name (add_struct_tags)"),
37613
- tag: z16.string().optional().describe("Tag key (add_struct_tags — e.g. 'json')"),
37614
- value: z16.string().optional().describe("Tag value (add_struct_tags — e.g. 'user_name,omitempty')"),
37615
- validate: z16.enum(["syntax", "full"]).optional().describe("Validation level: 'syntax' (default) or 'full'. Syntax = tree-sitter parse check only. Full = also runs LSP type-checking (slower, catches more errors)")
37616
- },
37617
- execute: async (args, context) => {
37618
- const op = args.op;
37619
- if (op === "add_member") {
37620
- if (isEmptyParam(args.container))
37621
- throw new Error("'container' is required for 'add_member' op");
37622
- if (isEmptyParam(args.code))
37623
- throw new Error("'code' is required for 'add_member' op");
37624
- }
37625
- if (op === "add_derive" || op === "wrap_try_catch" || op === "add_decorator" || op === "add_struct_tags") {
37626
- if (isEmptyParam(args.target))
37627
- throw new Error(`'target' is required for '${op}' op`);
37628
- }
37629
- if (op === "add_derive" && isEmptyParam(args.derives)) {
37630
- throw new Error("'derives' array is required for 'add_derive' op");
37631
- }
37632
- if (op === "add_decorator" && isEmptyParam(args.decorator)) {
37633
- throw new Error("'decorator' is required for 'add_decorator' op");
37634
- }
37635
- if (op === "add_struct_tags") {
37636
- if (isEmptyParam(args.field))
37637
- throw new Error("'field' is required for 'add_struct_tags' op");
37638
- if (isEmptyParam(args.tag))
37639
- throw new Error("'tag' is required for 'add_struct_tags' op");
37640
- if (isEmptyParam(args.value))
37641
- throw new Error("'value' is required for 'add_struct_tags' op");
37642
- }
37643
- const filePath = await resolvePathArg(ctx, context, args.filePath);
37644
- {
37645
- const denial = await assertExternalDirectoryPermission(context, filePath);
37646
- if (denial)
37647
- return permissionDeniedResponse(denial);
37648
- }
37649
- const permissionError = await askEditPermission(context, [resolveRelativePattern(context, filePath)], { filepath: filePath });
37650
- if (permissionError)
37651
- return permissionDeniedResponse(permissionError);
37652
- const params = { file: filePath };
37653
- if (args.validate !== undefined)
37654
- params.validate = args.validate;
37655
- switch (op) {
37656
- case "add_member":
37657
- params.scope = args.container;
37658
- params.code = args.code;
37659
- if (args.position !== undefined)
37660
- params.position = args.position;
37661
- break;
37662
- case "add_derive":
37663
- params.target = args.target;
37664
- params.derives = args.derives;
37665
- break;
37666
- case "wrap_try_catch":
37667
- params.target = args.target;
37668
- if (args.catchBody !== undefined)
37669
- params.catch_body = args.catchBody;
37670
- break;
37671
- case "add_decorator":
37672
- params.target = args.target;
37673
- params.decorator = args.decorator;
37674
- if (args.position !== undefined)
37675
- params.position = args.position;
37676
- break;
37677
- case "add_struct_tags":
37678
- params.target = args.target;
37679
- params.field = args.field;
37680
- params.tag = args.tag;
37681
- params.value = args.value;
37682
- break;
37683
- }
37684
- const response = await callBridge(ctx, context, op, params);
37685
- if (response.success === false) {
37686
- throw new Error(response.message || `${op} failed`);
37687
- }
37688
- return JSON.stringify(response);
37689
- }
37690
- }
37691
- };
37692
- }
37693
-
37694
37887
  // src/workflow-hints.ts
37695
- var HEADING = "## Prefer AFT tools for token efficiency";
37888
+ var HEADING = "## IMPORTANT NOTICE about your tools";
37696
37889
  function buildWorkflowHints(opts) {
37697
37890
  const sections = [];
37698
37891
  const grepName = opts.hoistBuiltins ? "grep" : "aft_grep";
@@ -37708,14 +37901,28 @@ function buildWorkflowHints(opts) {
37708
37901
  const hasBash = !opts.disabledTools.has(bashName);
37709
37902
  const hasBgBash = hasBash && opts.bashBackgroundEnabled && !opts.disabledTools.has(bashStatusName);
37710
37903
  if (hasBash && opts.bashCompressionEnabled) {
37711
- sections.push("When AFT bash output compression is on, do NOT pipe test/build commands through grep/head/tail (e.g. `bun test | grep fail`) to summarize. The compressor already keeps failures and the summary; piping hides failures. Run the command bare.");
37904
+ sections.push([
37905
+ "**Test/build output**: bash output is auto-compressed — failures and the summary are always kept. DO NOT pipe test/build commands through filters to summarize; that hides failures:",
37906
+ "- `bun test | grep fail` → run `bun test`",
37907
+ "- `cargo test 2>&1 | tail -20` → run `cargo test`",
37908
+ "- `npm run build | head -50` → run `npm run build`"
37909
+ ].join(`
37910
+ `));
37712
37911
  }
37713
37912
  if (hasOutline && hasZoom) {
37714
37913
  sections.push(`**Web/URL access**: \`aft_outline({ target: url })\` first for structure, then \`aft_zoom({ url, symbols: "<heading>" })\` for the specific section.`);
37715
37914
  }
37716
37915
  if (hasOutline && hasZoom && (hasGrep || hasSearch)) {
37916
+ const searchName = hasSearch ? "aft_search" : grepName;
37717
37917
  const locate = hasSearch ? '`aft_search` is the primary code-search tool: one call auto-routes concepts, identifiers, regex, error strings, and literals (pass `hint: "regex"`/`"literal"`/`"semantic"` to force a lane).' : `\`${grepName}\` (the tool — indexed and ranked) locates code.`;
37718
- sections.push(`**Code exploration**: fire independent lookups in ONE parallel tool-call wave — do NOT serialize them. ${locate} Then \`aft_outline\` for structure → \`aft_zoom\` for symbol(s). DO NOT run \`grep\`/\`rg\`/\`find\` through \`bash\` to locate code — the bash path is unindexed, unranked, serial, and routinely surfaces the wrong hit. Keep \`bash\` for shell facts (git state, file metadata, processes).`);
37918
+ const readName = opts.hoistBuiltins ? "read" : "aft_read";
37919
+ sections.push([
37920
+ `**Code exploration**: ${locate} Then \`aft_outline\` for structure → \`aft_zoom\` for symbol(s). DO NOT run \`grep\`/\`rg\`/\`find\`/\`sed\`/\`cat\` through \`bash\` to locate or read code — the bash path is unindexed, unranked, serial, and routinely surfaces the wrong hit. Keep \`bash\` for shell facts (git state, file metadata, processes). Reflex translations:`,
37921
+ `- \`grep -rn "handleAuth" src/\` in bash → \`${searchName}({ query: "handleAuth" })\``,
37922
+ `- \`find . -name "*.ts" | xargs grep watcher\` in bash → \`${searchName}({ query: "watcher invalidation" })\` (concepts work too)`,
37923
+ `- \`sed -n '100,160p' app.ts\` / \`cat app.ts\` in bash → \`${readName}({ filePath: "app.ts", startLine: 100, endLine: 160 })\``
37924
+ ].join(`
37925
+ `));
37719
37926
  }
37720
37927
  if (hasInspect) {
37721
37928
  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.");
@@ -37733,13 +37940,22 @@ function buildWorkflowHints(opts) {
37733
37940
  `));
37734
37941
  }
37735
37942
  if (hasBash && hasBgBash) {
37736
- sections.push(`**Long-running commands** (builds, installs, full test suites): \`${bashName}({ background: true })\` returns immediately with a \`taskId\`, then **end your turn** — a completion reminder arrives automatically when it finishes. Do not poll \`${bashStatusName}({ taskId })\`, and do not sync-wait with \`bash_watch\` for a long task: blocking freezes your turn and locks the user out until it ends. For an early non-blocking ping on a specific output line, register an async watch \`bash_watch({ taskId, pattern, background: true })\`. \`bash_watch\` synchronous mode is only for short bounded waits (seconds, e.g. a dev server printing a readiness line), never for multi-minute jobs.`);
37943
+ sections.push([
37944
+ `**Long-running commands** (builds, installs, full test suites): \`${bashName}({ background: true })\` returns a \`taskId\` immediately. Then do ONE of these:`,
37945
+ "1. Keep working on something independent of the result.",
37946
+ "2. End your turn — a completion reminder with the result arrives automatically.",
37947
+ `3. Need to react to a specific output line early? Register \`bash_watch({ taskId, pattern, background: true })\`, then end your turn — it pings you the moment the pattern appears.`,
37948
+ `\`${bashStatusName}({ taskId })\` is for inspecting output AFTER the reminder arrives, or one quick look at a live task — never call it repeatedly to wait: the reminder already delivers the result, and each poll wastes a turn. Sync \`bash_watch\` (without \`background: true\`) blocks your turn and locks the user out — reserve it for waits of a few seconds (a dev server printing its ready line), never for builds or test suites.`
37949
+ ].join(`
37950
+ `));
37737
37951
  sections.push(`**PTY / interactive commands**: PTY mode is for interactive REPLs and terminal apps (python, node, bash itself, vim). Start with \`${bashName}({ command: "python", pty: true, background: true })\`, read the screen with \`${bashStatusName}({ taskId, outputMode: "screen" })\`, and send input with \`${bashWriteName}({ taskId, input: "..." })\`.`);
37738
37952
  }
37739
37953
  if (sections.length === 0) {
37740
37954
  return null;
37741
37955
  }
37742
- sections.unshift("**Parallel tool calls**: when several read-only operations are independent, emit them in ONE response instead of serializing file reads, structure and symbol lookups, code search, diagnostics, and git status/diff/log. Sequence only when a call depends on a prior result or when a command mutates state.");
37956
+ sections.unshift(`You are equipped with a non-standard tool set: indexed code search, symbol-level reading, structural editing, and code analysis that are faster, more precise, and far cheaper in tokens than stitching together command-line utilities in bash. Always reach for these tools first.
37957
+
37958
+ **Parallel tool calls**: when several read-only operations are independent, emit them in ONE response instead of serializing — file reads, structure and symbol lookups, code search, diagnostics, and git status/diff/log. Sequence only when a call depends on a prior result or when a command mutates state.`);
37743
37959
  return `${HEADING}
37744
37960
 
37745
37961
  ${sections.join(`
@@ -37801,12 +38017,12 @@ var PLUGIN_VERSION = (() => {
37801
38017
  return "0.0.0";
37802
38018
  }
37803
38019
  })();
37804
- var ANNOUNCEMENT_VERSION = "0.36.0";
38020
+ var ANNOUNCEMENT_VERSION = "0.37.0";
37805
38021
  var ANNOUNCEMENT_FEATURES = [
37806
- "Persisted call graph: dead-code analysis runs on large repositories again (the file-count cap is gone), `aft_callgraph` queries resolve from disk, and method-call edges are more accurate across more languages.",
37807
- "Bash output stops hiding failures: piping a test/build through `grep`/`head` with compression on now keeps the failures and summary instead of stripping them.",
37808
- "Code search steering: guidance and hints now point to `aft_search` and parallel `aft_*` tools instead of `grep`/`find` in bash.",
37809
- "Fixes: a small `timeout` no longer kills a foreground `bash` command (#102), and `aft_delete`/`aft_safety checkpoint` no longer crash on a mistyped `files` argument."
38022
+ "Much lower background CPU: idle bridges shut down after 30 minutes, deleted project roots stop the watcher instead of spinning it, and background scans reuse warm caches on a bounded thread pool.",
38023
+ "`aft_transform` removed: usage data showed agents never called it `edit` covers everything it did, and every request now carries a smaller tool surface.",
38024
+ "Leaner tools: `bash`, `write`, `edit`, `apply_patch`, `aft_search`, and `aft_refactor` descriptions trimmed and config-aware; system-prompt guidance rewritten around what to do instead of what to avoid.",
38025
+ "Background bash reminders are right-sized: failures keep head + tail context, successes show a short tail, and compressors never print a success summary for a non-zero exit."
37810
38026
  ];
37811
38027
  var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/DSa65w8wuf";
37812
38028
  var plugin = async (input) => initializePluginForDirectory(input);
@@ -38031,17 +38247,25 @@ ${lines}
38031
38247
  });
38032
38248
  rpcServer.handle("status", async (params) => {
38033
38249
  const sessionID = params.sessionID || "rpc";
38034
- const cachedDir = getSessionDirectoryCached(sessionID);
38035
- const candidateDirs = new Set;
38036
- candidateDirs.add(input.directory);
38037
- if (typeof cachedDir === "string" && cachedDir.length > 0) {
38038
- candidateDirs.add(cachedDir);
38039
- }
38040
38250
  let bridge = null;
38041
- for (const dir of candidateDirs) {
38042
- bridge = pool.getActiveBridgeForRoot(dir);
38043
- if (bridge)
38044
- break;
38251
+ let servedDirectory = input.directory;
38252
+ const realSessionID = params.sessionID || "";
38253
+ const verifiedDir = realSessionID ? await verifySessionDirectory(input.client, realSessionID) : null;
38254
+ if (verifiedDir) {
38255
+ bridge = pool.getActiveBridgeForRoot(verifiedDir);
38256
+ if (bridge) {
38257
+ servedDirectory = verifiedDir;
38258
+ } else if (verifiedDir !== input.directory) {
38259
+ return {
38260
+ success: true,
38261
+ status: "not_initialized",
38262
+ message: "AFT bridge is now spawned lazily, information here will be populated after first tool call."
38263
+ };
38264
+ }
38265
+ }
38266
+ if (!bridge) {
38267
+ bridge = pool.getActiveBridgeForRoot(input.directory);
38268
+ servedDirectory = input.directory;
38045
38269
  }
38046
38270
  if (!bridge) {
38047
38271
  return {
@@ -38054,13 +38278,13 @@ ${lines}
38054
38278
  const cachedSessionId = cached2?.session;
38055
38279
  const cachedId = cachedSessionId?.id;
38056
38280
  if (cached2 !== null && cachedId === sessionID) {
38057
- return { success: true, ...cached2 };
38281
+ return { success: true, ...cached2, served_directory: servedDirectory };
38058
38282
  }
38059
38283
  const response = await bridge.send("status", { session_id: sessionID });
38060
38284
  if (response.success !== false) {
38061
38285
  bridge.cacheStatusSnapshot(response);
38062
38286
  }
38063
- return response;
38287
+ return { ...response, served_directory: servedDirectory };
38064
38288
  });
38065
38289
  const storageDir = configOverrides.storage_dir;
38066
38290
  rpcServer.handle("get-announcement", async () => {
@@ -38120,19 +38344,12 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38120
38344
  cleanupWarnings(notifyOpts).catch(() => {});
38121
38345
  }
38122
38346
  const surface = aftConfig.tool_surface ?? "recommended";
38123
- const ALL_ONLY_TOOLS = new Set([
38124
- "aft_callgraph",
38125
- "aft_delete",
38126
- "aft_move",
38127
- "aft_transform",
38128
- "aft_refactor"
38129
- ]);
38347
+ const ALL_ONLY_TOOLS = new Set(["aft_callgraph", "aft_delete", "aft_move", "aft_refactor"]);
38130
38348
  const allTools = normalizeToolMap({
38131
38349
  ...surface !== "minimal" && (aftConfig.hoist_builtin_tools !== false ? hoistedTools(ctx) : aftPrefixedTools(ctx)),
38132
38350
  ...readingTools(ctx),
38133
38351
  ...safetyTools(ctx),
38134
38352
  ...surface !== "minimal" && importTools(ctx),
38135
- ...structureTools(ctx),
38136
38353
  ...navigationTools(ctx),
38137
38354
  ...surface !== "minimal" && astTools(ctx),
38138
38355
  ...surface !== "minimal" && aftConfig.semantic_search === true && semanticTools(ctx),
@@ -38179,7 +38396,16 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38179
38396
  "bash_status"
38180
38397
  ];
38181
38398
  const registeredTools = new Set(Object.keys(allTools));
38182
- pool.setConfigureOverride("aft_search_registered", registeredTools.has("aft_search"));
38399
+ const aftSearchRegistered = registeredTools.has("aft_search");
38400
+ pool.setConfigureOverride("aft_search_registered", aftSearchRegistered);
38401
+ ctx.aftSearchRegistered = aftSearchRegistered;
38402
+ for (const name of ["bash", "aft_bash"]) {
38403
+ const def = allTools[name];
38404
+ if (def) {
38405
+ const bashCfg = resolveBashConfig(aftConfig);
38406
+ def.description = bashToolDescription(aftSearchRegistered, bashCfg.compress, bashCfg.background);
38407
+ }
38408
+ }
38183
38409
  const hintsAbsentTools = new Set;
38184
38410
  for (const name of HINTS_TOOL_NAMES) {
38185
38411
  if (!registeredTools.has(name))