@cortexkit/aft-opencode 0.36.0 → 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.
Files changed (40) hide show
  1. package/dist/bg-notifications.d.ts +5 -1
  2. package/dist/bg-notifications.d.ts.map +1 -1
  3. package/dist/index.d.ts +0 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1594 -1159
  6. package/dist/shared/rpc-client.d.ts +13 -0
  7. package/dist/shared/rpc-client.d.ts.map +1 -1
  8. package/dist/shared/rpc-server.d.ts +7 -0
  9. package/dist/shared/rpc-server.d.ts.map +1 -1
  10. package/dist/shared/rpc-utils.d.ts +23 -0
  11. package/dist/shared/rpc-utils.d.ts.map +1 -1
  12. package/dist/shared/session-directory.d.ts +1 -0
  13. package/dist/shared/session-directory.d.ts.map +1 -1
  14. package/dist/status-bar-inject.d.ts +1 -1
  15. package/dist/status-bar-inject.d.ts.map +1 -1
  16. package/dist/tool-perf.d.ts +17 -0
  17. package/dist/tool-perf.d.ts.map +1 -0
  18. package/dist/tools/_shared.d.ts +1 -11
  19. package/dist/tools/_shared.d.ts.map +1 -1
  20. package/dist/tools/bash.d.ts +21 -1
  21. package/dist/tools/bash.d.ts.map +1 -1
  22. package/dist/tools/conflicts.d.ts.map +1 -1
  23. package/dist/tools/hoisted.d.ts.map +1 -1
  24. package/dist/tools/reading.d.ts.map +1 -1
  25. package/dist/tools/refactoring.d.ts.map +1 -1
  26. package/dist/tools/safety.d.ts.map +1 -1
  27. package/dist/tools/semantic.d.ts.map +1 -1
  28. package/dist/tui.js +118 -95
  29. package/dist/workflow-hints.d.ts.map +1 -1
  30. package/package.json +8 -8
  31. package/src/shared/rpc-client.ts +79 -26
  32. package/src/shared/rpc-server.ts +64 -6
  33. package/src/shared/rpc-utils.ts +62 -0
  34. package/src/shared/session-directory.ts +56 -0
  35. package/src/tui/index.tsx +26 -2
  36. package/src/tui/sidebar.tsx +64 -1
  37. package/dist/metadata-store.d.ts +0 -29
  38. package/dist/metadata-store.d.ts.map +0 -1
  39. package/dist/tools/structure.d.ts +0 -8
  40. package/dist/tools/structure.d.ts.map +0 -1
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}`);
@@ -12781,6 +13002,11 @@ function formatEditSummary(data) {
12781
13002
  const n = data.files_modified;
12782
13003
  return `Applied edits to ${n} file${n === 1 ? "" : "s"}.`;
12783
13004
  }
13005
+ if (typeof data.total_files === "number") {
13006
+ const files = data.total_files;
13007
+ const reps = data.total_replacements ?? 0;
13008
+ return `Edited ${files} file${files === 1 ? "" : "s"} (${reps} replacement${reps === 1 ? "" : "s"}).`;
13009
+ }
12784
13010
  const additions = data.diff?.additions ?? 0;
12785
13011
  const deletions = data.diff?.deletions ?? 0;
12786
13012
  const counts = `+${additions}/-${deletions}`;
@@ -13994,7 +14220,23 @@ function isProcessAlive(pid) {
13994
14220
  }
13995
14221
  }
13996
14222
  // ../aft-bridge/dist/pipe-strip.js
13997
- var NOISE_FILTERS = new Set(["grep", "rg", "head", "tail", "cat", "less", "more"]);
14223
+ var NOISE_FILTERS = new Set([
14224
+ "grep",
14225
+ "rg",
14226
+ "head",
14227
+ "tail",
14228
+ "cat",
14229
+ "less",
14230
+ "more",
14231
+ "sed",
14232
+ "awk",
14233
+ "cut",
14234
+ "sort",
14235
+ "uniq",
14236
+ "tr",
14237
+ "column",
14238
+ "fold"
14239
+ ]);
13998
14240
  var GREP_GUARD_FLAGS = new Set([
13999
14241
  "c",
14000
14242
  "count",
@@ -14008,6 +14250,8 @@ var GREP_GUARD_FLAGS = new Set([
14008
14250
  function maybeStripCompressorPipe(command, compressionEnabled) {
14009
14251
  if (!compressionEnabled)
14010
14252
  return { command, stripped: false };
14253
+ if (containsUnsplittableConstruct(command))
14254
+ return { command, stripped: false };
14011
14255
  const chain = splitTopLevelAndChain(command);
14012
14256
  if (chain === null)
14013
14257
  return { command, stripped: false };
@@ -14021,7 +14265,7 @@ function maybeStripCompressorPipe(command, compressionEnabled) {
14021
14265
  return { command, stripped: false };
14022
14266
  const filterStages = stages.slice(1).map((stage) => stage.trim());
14023
14267
  for (const stage of filterStages) {
14024
- if (!isPlainNoiseFilter(stage))
14268
+ if (!filterStageIsSafeToDrop(stage))
14025
14269
  return { command, stripped: false };
14026
14270
  }
14027
14271
  const filters = filterStages.join(" | ");
@@ -14067,6 +14311,14 @@ function splitTopLevelAndChain(command) {
14067
14311
  return null;
14068
14312
  if (char === ";")
14069
14313
  return null;
14314
+ if (char === `
14315
+ ` || char === "\r")
14316
+ return null;
14317
+ if (char === "&") {
14318
+ const prev = command[index - 1];
14319
+ if (prev !== ">" && next !== ">")
14320
+ return null;
14321
+ }
14070
14322
  }
14071
14323
  segments.push(command.slice(start));
14072
14324
  return segments;
@@ -14109,45 +14361,262 @@ function isCompressorHandledRunner(stage) {
14109
14361
  const tokens = tokenizeStage(stage);
14110
14362
  if (tokens.length === 0)
14111
14363
  return false;
14112
- const [first, second, third] = tokens;
14113
- if (!first)
14114
- return false;
14115
14364
  if (tokens.some((token) => token === "&&" || token === "||" || token.includes(";"))) {
14116
14365
  return false;
14117
14366
  }
14367
+ const first = runnerName(tokens[0]);
14368
+ const second = tokens[1];
14369
+ const third = tokens[2];
14370
+ const rest = tokens.slice(1);
14371
+ if (!first)
14372
+ return false;
14118
14373
  if (first === "bun")
14119
14374
  return second === "test" || second === "run" && startsWithTest(third);
14120
- if (first === "cargo")
14121
- return ["test", "build", "check", "clippy"].includes(second ?? "");
14122
- if (first === "go")
14123
- return second === "test" || second === "build";
14124
- if (["npm", "pnpm"].includes(first)) {
14375
+ if (first === "npm" || first === "pnpm") {
14125
14376
  return second === "test" || second === "run" && startsWithTest(third);
14126
14377
  }
14127
- if (first === "yarn")
14128
- return second === "test";
14378
+ if (first === "yarn") {
14379
+ return startsWithTest(second) || second === "run" && startsWithTest(third);
14380
+ }
14381
+ if (first === "deno")
14382
+ return ["test", "lint", "check", "bench"].includes(second ?? "");
14383
+ if (first === "npx") {
14384
+ return ["tsc", "eslint", "vitest", "jest", "playwright", "biome"].includes(second ?? "");
14385
+ }
14129
14386
  if (first === "playwright")
14130
14387
  return second === "test";
14131
- if (first === "npx")
14132
- return ["tsc", "eslint", "vitest", "jest"].includes(second ?? "");
14133
- return ["vitest", "jest", "pytest", "tsc", "eslint", "biome", "ruff", "mypy"].includes(first);
14388
+ if (first === "cargo") {
14389
+ return ["test", "build", "check", "clippy", "nextest"].includes(second ?? "");
14390
+ }
14391
+ if (first === "go")
14392
+ return ["test", "build", "vet"].includes(second ?? "");
14393
+ if (first === "gradle" || first === "gradlew") {
14394
+ return hasBuildTask(rest, ["test", "check", "build", "assemble", "clean"]);
14395
+ }
14396
+ if (first === "mvn" || first === "mvnw") {
14397
+ return hasBuildTask(rest, ["test", "verify", "package", "install"]);
14398
+ }
14399
+ if (first === "dotnet")
14400
+ return ["test", "build"].includes(second ?? "");
14401
+ if (first === "rspec")
14402
+ return true;
14403
+ if (first === "rake")
14404
+ return second === "test" || second === "spec";
14405
+ if (first === "phpunit" || first === "pest")
14406
+ return true;
14407
+ if (first === "xcodebuild")
14408
+ return xcodebuildHasBuildAction(rest);
14409
+ if (first === "swift")
14410
+ return second === "test" || second === "build";
14411
+ if (first === "make" || first === "gmake") {
14412
+ return hasBuildTask(rest, ["test", "check", "lint", "clean"]);
14413
+ }
14414
+ return [
14415
+ "vitest",
14416
+ "jest",
14417
+ "pytest",
14418
+ "tsc",
14419
+ "eslint",
14420
+ "biome",
14421
+ "ruff",
14422
+ "mypy",
14423
+ "tox",
14424
+ "nox"
14425
+ ].includes(first);
14426
+ }
14427
+ function runnerName(token) {
14428
+ if (!token)
14429
+ return "";
14430
+ const slash = token.lastIndexOf("/");
14431
+ return slash === -1 ? token : token.slice(slash + 1);
14432
+ }
14433
+ function hasBuildTask(args, tasks) {
14434
+ const isAllowedTask = (arg) => tasks.some((task) => arg === task || arg.endsWith(`:${task}`));
14435
+ const isFlagOrProperty = (arg) => arg.startsWith("-") || arg.includes("=");
14436
+ let sawAllowed = false;
14437
+ for (const arg of args) {
14438
+ if (isFlagOrProperty(arg))
14439
+ continue;
14440
+ if (!isAllowedTask(arg))
14441
+ return false;
14442
+ sawAllowed = true;
14443
+ }
14444
+ return sawAllowed;
14134
14445
  }
14135
14446
  function startsWithTest(token) {
14136
14447
  return token?.startsWith("test") === true;
14137
14448
  }
14138
- function isPlainNoiseFilter(stage) {
14139
- const tokens = tokenizeStage(stage);
14140
- const head = tokens[0];
14449
+ var XCODEBUILD_VALUE_FLAGS = new Set([
14450
+ "-scheme",
14451
+ "-target",
14452
+ "-project",
14453
+ "-workspace",
14454
+ "-configuration",
14455
+ "-sdk",
14456
+ "-destination",
14457
+ "-arch",
14458
+ "-derivedDataPath",
14459
+ "-resultBundlePath",
14460
+ "-xcconfig",
14461
+ "-toolchain"
14462
+ ]);
14463
+ var XCODEBUILD_BUILD_ACTIONS = new Set([
14464
+ "build",
14465
+ "test",
14466
+ "build-for-testing",
14467
+ "test-without-building",
14468
+ "analyze"
14469
+ ]);
14470
+ function xcodebuildHasBuildAction(args) {
14471
+ for (let i = 0;i < args.length; i++) {
14472
+ const arg = args[i];
14473
+ if (arg.startsWith("-")) {
14474
+ if (XCODEBUILD_VALUE_FLAGS.has(arg))
14475
+ i++;
14476
+ continue;
14477
+ }
14478
+ if (XCODEBUILD_BUILD_ACTIONS.has(arg))
14479
+ return true;
14480
+ }
14481
+ return false;
14482
+ }
14483
+ function containsUnsplittableConstruct(command) {
14484
+ let quote = null;
14485
+ let escaped = false;
14486
+ for (let i = 0;i < command.length; i++) {
14487
+ const char = command[i];
14488
+ if (escaped) {
14489
+ escaped = false;
14490
+ continue;
14491
+ }
14492
+ if (char === "\\" && quote !== "'") {
14493
+ escaped = true;
14494
+ continue;
14495
+ }
14496
+ if (quote === "'") {
14497
+ if (char === "'")
14498
+ quote = null;
14499
+ continue;
14500
+ }
14501
+ if (quote === '"') {
14502
+ if (char === '"')
14503
+ quote = null;
14504
+ else if (char === "`")
14505
+ return true;
14506
+ else if (char === "$" && command[i + 1] === "(")
14507
+ return true;
14508
+ continue;
14509
+ }
14510
+ if (char === "'" || char === '"') {
14511
+ quote = char;
14512
+ continue;
14513
+ }
14514
+ if (char === "`")
14515
+ return true;
14516
+ if (char === "(" || char === ")")
14517
+ return true;
14518
+ }
14519
+ return false;
14520
+ }
14521
+ var READS_FILE_OPERAND = new Set(["cat", "tac", "nl", "less", "more"]);
14522
+ function filterStageIsSafeToDrop(stage) {
14523
+ const head = tokenizeStage(stage)[0];
14141
14524
  if (!head)
14142
14525
  return false;
14143
14526
  if (head === "wc")
14144
14527
  return false;
14145
14528
  if (!NOISE_FILTERS.has(head))
14146
14529
  return false;
14147
- if ((head === "grep" || head === "rg") && hasIntentChangingGrepFlag(tokens.slice(1)))
14530
+ if (/[<>]/.test(stage))
14531
+ return false;
14532
+ if (hasUnquotedBackground(stage))
14533
+ return false;
14534
+ const args = tokenizeStage(stage).slice(1);
14535
+ const hasFlag = (...names) => args.some((a) => names.some((n) => a === n || a.startsWith(`${n}=`)));
14536
+ if (head === "grep" || head === "rg") {
14537
+ if (hasIntentChangingGrepFlag(args))
14538
+ return false;
14539
+ if (countBareOperands(args) > 1)
14540
+ return false;
14541
+ return true;
14542
+ }
14543
+ if (head === "head" || head === "tail") {
14544
+ if (bareOperands(args).some((op) => !/^\d+$/.test(op)))
14545
+ return false;
14546
+ return true;
14547
+ }
14548
+ if (READS_FILE_OPERAND.has(head)) {
14549
+ if (countBareOperands(args) > 0)
14550
+ return false;
14551
+ return true;
14552
+ }
14553
+ if (head === "sed") {
14554
+ if (hasFlag("-i", "--in-place"))
14555
+ return false;
14556
+ if (countBareOperands(args) > 1)
14557
+ return false;
14558
+ return true;
14559
+ }
14560
+ if (head === "awk") {
14561
+ if (countBareOperands(args) > 1)
14562
+ return false;
14563
+ return true;
14564
+ }
14565
+ if (head === "sort" && hasFlag("-o", "--output"))
14566
+ return false;
14567
+ if (bareOperands(args).some((op) => op.includes("/") || op.includes(".")))
14148
14568
  return false;
14149
14569
  return true;
14150
14570
  }
14571
+ function bareOperands(args) {
14572
+ const out = [];
14573
+ let afterDoubleDash = false;
14574
+ for (const arg of args) {
14575
+ if (!afterDoubleDash && arg === "--") {
14576
+ afterDoubleDash = true;
14577
+ continue;
14578
+ }
14579
+ if (!afterDoubleDash && arg.startsWith("-") && arg !== "-")
14580
+ continue;
14581
+ out.push(arg);
14582
+ }
14583
+ return out;
14584
+ }
14585
+ function countBareOperands(args) {
14586
+ return bareOperands(args).length;
14587
+ }
14588
+ function hasUnquotedBackground(stage) {
14589
+ let quote = null;
14590
+ let escaped = false;
14591
+ for (let i = 0;i < stage.length; i++) {
14592
+ const char = stage[i];
14593
+ if (escaped) {
14594
+ escaped = false;
14595
+ continue;
14596
+ }
14597
+ if (char === "\\" && quote !== "'") {
14598
+ escaped = true;
14599
+ continue;
14600
+ }
14601
+ if (quote) {
14602
+ if (char === quote)
14603
+ quote = null;
14604
+ continue;
14605
+ }
14606
+ if (char === "'" || char === '"') {
14607
+ quote = char;
14608
+ continue;
14609
+ }
14610
+ if (char === "&") {
14611
+ const prev = stage[i - 1];
14612
+ const next = stage[i + 1];
14613
+ if (prev === "&" || next === "&" || prev === ">" || next === ">")
14614
+ continue;
14615
+ return true;
14616
+ }
14617
+ }
14618
+ return false;
14619
+ }
14151
14620
  function hasIntentChangingGrepFlag(args) {
14152
14621
  for (const arg of args) {
14153
14622
  if (arg === "--")
@@ -14210,7 +14679,7 @@ function tokenizeStage(stage) {
14210
14679
  }
14211
14680
  // ../aft-bridge/dist/pool.js
14212
14681
  import { realpathSync as realpathSync2 } from "node:fs";
14213
- var DEFAULT_IDLE_TIMEOUT_MS = Infinity;
14682
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
14214
14683
  var DEFAULT_MAX_POOL_SIZE = 8;
14215
14684
  var CLEANUP_INTERVAL_MS = 60 * 1000;
14216
14685
  class BridgePool {
@@ -14284,7 +14753,7 @@ class BridgePool {
14284
14753
  cleanup() {
14285
14754
  const now = Date.now();
14286
14755
  for (const [dir, entry] of this.bridges) {
14287
- if (entry.bridge.hasPendingRequests())
14756
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14288
14757
  continue;
14289
14758
  if (now - entry.lastUsed > this.idleTimeoutMs) {
14290
14759
  entry.bridge.shutdown().catch((err) => this.error("cleanup shutdown failed:", err));
@@ -14292,7 +14761,7 @@ class BridgePool {
14292
14761
  }
14293
14762
  }
14294
14763
  for (const bridge of this.staleBridges) {
14295
- if (bridge.hasPendingRequests())
14764
+ if (bridge.hasPendingRequests() || bridge.hasOutstandingBackgroundTasks())
14296
14765
  continue;
14297
14766
  bridge.shutdown().catch((err) => this.error("stale cleanup shutdown failed:", err));
14298
14767
  this.staleBridges.delete(bridge);
@@ -14302,7 +14771,7 @@ class BridgePool {
14302
14771
  let oldestDir = null;
14303
14772
  let oldestTime = Infinity;
14304
14773
  for (const [dir, entry] of this.bridges) {
14305
- if (entry.bridge.hasPendingRequests())
14774
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14306
14775
  continue;
14307
14776
  if (entry.lastUsed < oldestTime) {
14308
14777
  oldestTime = entry.lastUsed;
@@ -32515,38 +32984,6 @@ function discoverRelevantGithubServers(projectRoot) {
32515
32984
  return relevant;
32516
32985
  }
32517
32986
 
32518
- // src/metadata-store.ts
32519
- var pendingStore = new Map;
32520
- var STALE_TIMEOUT_MS = 15 * 60 * 1000;
32521
- function makeKey(sessionID, callID) {
32522
- return `${sessionID}:${callID}`;
32523
- }
32524
- function cleanupStaleEntries() {
32525
- const now = Date.now();
32526
- for (const [key, entry] of pendingStore) {
32527
- if (now - entry.storedAt > STALE_TIMEOUT_MS) {
32528
- pendingStore.delete(key);
32529
- }
32530
- }
32531
- }
32532
- function storeToolMetadata(sessionID, callID, data) {
32533
- cleanupStaleEntries();
32534
- pendingStore.set(makeKey(sessionID, callID), {
32535
- ...data,
32536
- storedAt: Date.now()
32537
- });
32538
- }
32539
- function consumeToolMetadata(sessionID, callID) {
32540
- const key = makeKey(sessionID, callID);
32541
- const stored = pendingStore.get(key);
32542
- if (stored) {
32543
- pendingStore.delete(key);
32544
- const { storedAt: _, ...data } = stored;
32545
- return data;
32546
- }
32547
- return;
32548
- }
32549
-
32550
32987
  // src/normalize-schemas.ts
32551
32988
  import { tool } from "@opencode-ai/plugin";
32552
32989
  function stripRootJsonSchemaFields(jsonSchema) {
@@ -32665,7 +33102,14 @@ function writeTerminal(terminal, data) {
32665
33102
 
32666
33103
  // src/shared/rpc-server.ts
32667
33104
  import { randomBytes as randomBytes2 } from "node:crypto";
32668
- 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";
32669
33113
  import { createServer } from "node:http";
32670
33114
  import { dirname as dirname9, join as join19 } from "node:path";
32671
33115
 
@@ -32680,6 +33124,41 @@ function rpcPortFileDir(storageDir, directory) {
32680
33124
  const hash2 = projectHash(directory);
32681
33125
  return join18(storageDir, "rpc", hash2, "ports");
32682
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
+ }
32683
33162
 
32684
33163
  // src/shared/rpc-server.ts
32685
33164
  class AftRpcServer {
@@ -32718,12 +33197,15 @@ class AftRpcServer {
32718
33197
  const dir = dirname9(this.portFilePath);
32719
33198
  mkdirSync10(dir, { recursive: true, mode: 448 });
32720
33199
  const tmpPath = `${this.portFilePath}.tmp`;
32721
- writeFileSync10(tmpPath, JSON.stringify({ port: this.port, token: this.token }), {
32722
- encoding: "utf-8",
32723
- mode: 384
32724
- });
33200
+ writeFileSync10(tmpPath, JSON.stringify({
33201
+ port: this.port,
33202
+ token: this.token,
33203
+ pid: process.pid,
33204
+ started_at: Date.now()
33205
+ }), { encoding: "utf-8", mode: 384 });
32725
33206
  renameSync8(tmpPath, this.portFilePath);
32726
33207
  log2(`RPC server listening on 127.0.0.1:${this.port}`);
33208
+ this.sweepDeadPortFiles();
32727
33209
  } catch (err) {
32728
33210
  warn2(`Failed to write RPC port file: ${err}`);
32729
33211
  }
@@ -32732,6 +33214,31 @@ class AftRpcServer {
32732
33214
  server.unref();
32733
33215
  });
32734
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
+ }
32735
33242
  stop() {
32736
33243
  if (this.server) {
32737
33244
  this.server.close();
@@ -32856,15 +33363,47 @@ function warmSessionDirectory(client, sessionId, fallbackDirectory) {
32856
33363
  return;
32857
33364
  getSessionDirectory(client, sessionId, fallbackDirectory);
32858
33365
  }
32859
-
32860
- // src/shared/status.ts
32861
- function asRecord(value) {
32862
- return typeof value === "object" && value !== null ? value : {};
32863
- }
32864
- function readString(value, fallback = "") {
32865
- return typeof value === "string" ? value : fallback;
32866
- }
32867
- function readNullableString(value) {
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
+ }
33398
+
33399
+ // src/shared/status.ts
33400
+ function asRecord(value) {
33401
+ return typeof value === "object" && value !== null ? value : {};
33402
+ }
33403
+ function readString(value, fallback = "") {
33404
+ return typeof value === "string" ? value : fallback;
33405
+ }
33406
+ function readNullableString(value) {
32868
33407
  return typeof value === "string" ? value : null;
32869
33408
  }
32870
33409
  function readBoolean(value, fallback = false) {
@@ -33068,7 +33607,7 @@ function formatStatusMarkdown(status) {
33068
33607
 
33069
33608
  // src/shared/tui-config.ts
33070
33609
  var import_comment_json4 = __toESM(require_src2(), 1);
33071
- 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";
33072
33611
  import { dirname as dirname10, join as join21 } from "node:path";
33073
33612
 
33074
33613
  // src/shared/opencode-config-dir.ts
@@ -33116,7 +33655,7 @@ function ensureTuiPluginEntry() {
33116
33655
  const configPath = resolveTuiConfigPath();
33117
33656
  let config2 = {};
33118
33657
  if (existsSync14(configPath)) {
33119
- config2 = import_comment_json4.parse(readFileSync13(configPath, "utf-8")) ?? {};
33658
+ config2 = import_comment_json4.parse(readFileSync14(configPath, "utf-8")) ?? {};
33120
33659
  }
33121
33660
  const plugins = Array.isArray(config2.plugin) ? config2.plugin.filter((value) => typeof value === "string") : [];
33122
33661
  if (plugins.some((plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) || plugin.includes("opencode-plugin") || plugin.includes("aft-opencode"))) {
@@ -33193,7 +33732,7 @@ function registerShutdownCleanup(fn) {
33193
33732
 
33194
33733
  // src/status-bar-inject.ts
33195
33734
  var emitStateBySession = new Map;
33196
- function statusBarSuffixForSession(sessionID, counts) {
33735
+ function statusBarSuffixForSession(sessionID, counts, force = false) {
33197
33736
  if (!counts)
33198
33737
  return "";
33199
33738
  let state = emitStateBySession.get(sessionID);
@@ -33201,12 +33740,67 @@ function statusBarSuffixForSession(sessionID, counts) {
33201
33740
  state = createStatusBarEmitState();
33202
33741
  emitStateBySession.set(sessionID, state);
33203
33742
  }
33743
+ if (force) {
33744
+ state.last = counts;
33745
+ state.callsSinceEmit = 0;
33746
+ return statusBarLine(counts);
33747
+ }
33204
33748
  return shouldEmitStatusBar(state, counts) ? statusBarLine(counts) : "";
33205
33749
  }
33206
33750
  function clearStatusBarSession(sessionID) {
33207
33751
  emitStateBySession.delete(sessionID);
33208
33752
  }
33209
33753
 
33754
+ // src/tool-perf.ts
33755
+ import { AsyncLocalStorage } from "node:async_hooks";
33756
+ var perfStore = new AsyncLocalStorage;
33757
+ function markBridgeStart() {
33758
+ const slot = perfStore.getStore();
33759
+ if (slot && slot.bridgeStart === undefined) {
33760
+ slot.bridgeStart = performance.now();
33761
+ }
33762
+ }
33763
+ function markBridgeEnd() {
33764
+ const slot = perfStore.getStore();
33765
+ if (slot) {
33766
+ slot.bridgeEnd = performance.now();
33767
+ }
33768
+ }
33769
+ function emit(slot) {
33770
+ const t3 = performance.now();
33771
+ const total = Math.round(t3 - slot.t0);
33772
+ if (slot.bridgeStart !== undefined && slot.bridgeEnd !== undefined) {
33773
+ const pre = Math.round(slot.bridgeStart - slot.t0);
33774
+ const bridge = Math.round(slot.bridgeEnd - slot.bridgeStart);
33775
+ const post = Math.round(t3 - slot.bridgeEnd);
33776
+ sessionLog(slot.sessionID, `perf tool=${slot.tool} total=${total}ms pre=${pre}ms bridge=${bridge}ms post=${post}ms`);
33777
+ } else {
33778
+ sessionLog(slot.sessionID, `perf tool=${slot.tool} total=${total}ms (no bridge call)`);
33779
+ }
33780
+ }
33781
+ function instrumentToolMap(tools) {
33782
+ for (const [name, def] of Object.entries(tools)) {
33783
+ const original = def.execute;
33784
+ if (typeof original !== "function")
33785
+ continue;
33786
+ def.execute = (args, context) => {
33787
+ const slot = {
33788
+ tool: name,
33789
+ sessionID: context?.sessionID,
33790
+ t0: performance.now()
33791
+ };
33792
+ return perfStore.run(slot, async () => {
33793
+ try {
33794
+ return await original(args, context);
33795
+ } finally {
33796
+ emit(slot);
33797
+ }
33798
+ });
33799
+ };
33800
+ }
33801
+ return tools;
33802
+ }
33803
+
33210
33804
  // src/tools/ast.ts
33211
33805
  import { tool as tool3 } from "@opencode-ai/plugin";
33212
33806
 
@@ -33229,19 +33823,6 @@ function isEmptyParam(value) {
33229
33823
  return false;
33230
33824
  }
33231
33825
  var BASH_TRANSPORT_TIMEOUT_MS = 30000;
33232
- var LONG_RUNNING_COMMAND_TIMEOUT_MS = {
33233
- callers: 60000,
33234
- trace_to: 60000,
33235
- trace_to_symbol: 60000,
33236
- trace_data: 60000,
33237
- impact: 60000,
33238
- grep: 60000,
33239
- glob: 60000,
33240
- semantic_search: 60000
33241
- };
33242
- function timeoutForCommand(command) {
33243
- return LONG_RUNNING_COMMAND_TIMEOUT_MS[command];
33244
- }
33245
33826
  function asPlainObject(value) {
33246
33827
  if (!value || typeof value !== "object" || Array.isArray(value))
33247
33828
  return;
@@ -33360,7 +33941,13 @@ async function callBridge(ctx, runtime, command, params = {}, options) {
33360
33941
  configureWarningClient: ctx.client,
33361
33942
  ...options
33362
33943
  };
33363
- const response = await bridgeFor(ctx, runtime).send(command, merged, Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
33944
+ markBridgeStart();
33945
+ let response;
33946
+ try {
33947
+ response = await bridgeFor(ctx, runtime).send(command, merged, Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
33948
+ } finally {
33949
+ markBridgeEnd();
33950
+ }
33364
33951
  ingestBgCompletions(runtime.sessionID, response.bg_completions);
33365
33952
  return response;
33366
33953
  }
@@ -33812,760 +34399,762 @@ ${hint}`;
33812
34399
  };
33813
34400
  }
33814
34401
 
33815
- // src/tools/conflicts.ts
34402
+ // src/tools/bash.ts
33816
34403
  import { tool as tool4 } from "@opencode-ai/plugin";
33817
- var z4 = tool4.schema;
33818
- function conflictTools(ctx) {
33819
- return {
33820
- aft_conflicts: {
33821
- 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).",
33822
- args: {
33823
- 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()
33824
- },
33825
- execute: async (args, context) => {
33826
- const params = {};
33827
- if (!isEmptyParam(args?.path))
33828
- params.path = args.path;
33829
- const response = await callBridge(ctx, context, "git_conflicts", params);
33830
- if (response.success === false) {
33831
- throw new Error(response.message || "git_conflicts failed");
33832
- }
33833
- return response.text;
33834
- }
33835
- }
33836
- };
33837
- }
33838
34404
 
33839
- // src/tools/hoisted.ts
33840
- import * as fs6 from "node:fs";
33841
- import * as path4 from "node:path";
33842
- import { tool as tool8 } from "@opencode-ai/plugin";
33843
-
33844
- // src/patch-parser.ts
33845
- function stripHeredoc(input) {
33846
- const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
33847
- return heredocMatch ? heredocMatch[2] : input;
33848
- }
33849
- function parsePatchHeader(lines, startIdx) {
33850
- const line = lines[startIdx];
33851
- if (line.startsWith("*** Add File:")) {
33852
- const filePath = line.slice("*** Add File:".length).trim();
33853
- 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;
33854
34412
  }
33855
- if (line.startsWith("*** Delete File:")) {
33856
- const filePath = line.slice("*** Delete File:".length).trim();
33857
- 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;
33858
34418
  }
33859
- if (line.startsWith("*** Update File:")) {
33860
- const filePath = line.slice("*** Update File:".length).trim();
33861
- let movePath;
33862
- let nextIdx = startIdx + 1;
33863
- if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
33864
- movePath = lines[nextIdx].slice("*** Move to:".length).trim();
33865
- nextIdx++;
33866
- }
33867
- 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;
33868
34425
  }
33869
- return null;
33870
- }
33871
- function parseAddFileContent(lines, startIdx) {
33872
- let content = "";
33873
- let i = startIdx;
33874
- while (i < lines.length && !lines[i].startsWith("***")) {
33875
- if (lines[i].startsWith("+")) {
33876
- content += `${lines[i].substring(1)}
33877
- `;
33878
- }
33879
- 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;
33880
34440
  }
33881
- if (content.endsWith(`
33882
- `)) {
33883
- content = content.slice(0, -1);
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);
33884
34452
  }
33885
- return { content, nextIdx: i };
33886
34453
  }
33887
- function parseUpdateFileChunks(lines, startIdx) {
33888
- const chunks = [];
33889
- let i = startIdx;
33890
- while (i < lines.length && !lines[i].startsWith("***")) {
33891
- if (lines[i].startsWith("@@")) {
33892
- const contextLine = lines[i].substring(2).trim();
33893
- i++;
33894
- const oldLines = [];
33895
- const newLines = [];
33896
- let isEndOfFile = false;
33897
- while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
33898
- const changeLine = lines[i];
33899
- if (changeLine === "*** End of File") {
33900
- isEndOfFile = true;
33901
- i++;
33902
- break;
33903
- }
33904
- if (changeLine.startsWith(" ")) {
33905
- const content = changeLine.substring(1);
33906
- oldLines.push(content);
33907
- newLines.push(content);
33908
- } else if (changeLine.startsWith("-")) {
33909
- oldLines.push(changeLine.substring(1));
33910
- } else if (changeLine.startsWith("+")) {
33911
- newLines.push(changeLine.substring(1));
33912
- }
33913
- i++;
33914
- }
33915
- chunks.push({
33916
- old_lines: oldLines,
33917
- new_lines: newLines,
33918
- change_context: contextLine || undefined,
33919
- is_end_of_file: isEndOfFile || undefined
33920
- });
33921
- } else {
33922
- i++;
33923
- }
34454
+
34455
+ // src/tools/bash.ts
34456
+ var z4 = tool4.schema;
34457
+ var METADATA_PREVIEW_LIMIT = 30 * 1024;
34458
+ var FOREGROUND_POLL_INTERVAL_MS = 100;
34459
+ var DEFAULT_HARD_TIMEOUT_MS = 30 * 60 * 1000;
34460
+ function resolveForegroundWaitMs(configured) {
34461
+ const override = process.env.AFT_TEST_FOREGROUND_WAIT_MS;
34462
+ if (override !== undefined) {
34463
+ const parsed = Number(override);
34464
+ if (Number.isFinite(parsed) && parsed >= 0)
34465
+ return parsed;
33924
34466
  }
33925
- return { chunks, nextIdx: i };
34467
+ return configured;
33926
34468
  }
33927
- var MAX_PATCH_SIZE = 1024 * 1024;
33928
- var MAX_HUNKS = 500;
33929
- function parsePatch(patchText) {
33930
- if (patchText.length > MAX_PATCH_SIZE) {
33931
- throw new Error(`Patch too large: ${patchText.length} bytes exceeds limit of ${MAX_PATCH_SIZE} bytes`);
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
+ }
34477
+ async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34478
+ const first = await bridgeCall(ctx, runtime, "bash", params, options);
34479
+ if (first.success !== false || first.code !== "permission_required")
34480
+ return first;
34481
+ const asks = Array.isArray(first.asks) ? first.asks : [];
34482
+ const permissionsGranted = [];
34483
+ for (const ask of asks) {
34484
+ const permission = ask.kind === "external_directory" ? "external_directory" : "bash";
34485
+ await runAsk(runtime.ask({
34486
+ permission,
34487
+ patterns: ask.patterns,
34488
+ always: ask.always,
34489
+ metadata: {}
34490
+ }));
34491
+ permissionsGranted.push(...ask.always.length > 0 ? ask.always : ask.patterns);
33932
34492
  }
33933
- const cleaned = stripHeredoc(patchText.trim());
33934
- const lines = cleaned.split(`
33935
- `);
33936
- const hunks = [];
33937
- const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
33938
- const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
33939
- if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
33940
- throw new Error("Invalid patch format: missing *** Begin Patch / *** End Patch markers");
34493
+ const second = await bridgeCall(ctx, runtime, "bash", { ...params, permissions_granted: permissionsGranted }, options);
34494
+ if (second.success === false && second.code === "permission_required") {
34495
+ throw new Error("bash permission retry failed");
33941
34496
  }
33942
- let i = beginIdx + 1;
33943
- while (i < endIdx) {
33944
- const header = parsePatchHeader(lines, i);
33945
- if (!header) {
33946
- i++;
33947
- continue;
33948
- }
33949
- if (hunks.length >= MAX_HUNKS) {
33950
- throw new Error(`Patch exceeds maximum of ${MAX_HUNKS} file operations`);
33951
- }
33952
- if (lines[i].startsWith("*** Add File:")) {
33953
- const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
33954
- hunks.push({ type: "add", path: header.filePath, contents: content });
33955
- i = nextIdx;
33956
- } else if (lines[i].startsWith("*** Delete File:")) {
33957
- hunks.push({ type: "delete", path: header.filePath });
33958
- i = header.nextIdx;
33959
- } else if (lines[i].startsWith("*** Update File:")) {
33960
- const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
33961
- hunks.push({
33962
- type: "update",
33963
- path: header.filePath,
33964
- move_path: header.movePath,
33965
- chunks
34497
+ return second;
34498
+ }
34499
+ function createBashTool(ctx, aftSearchRegisteredOverride) {
34500
+ return {
34501
+ description: (() => {
34502
+ const cfg = resolveBashConfig(ctx.config);
34503
+ return bashToolDescription(false, cfg.compress, cfg.background);
34504
+ })(),
34505
+ args: {
34506
+ command: z4.string().describe("Shell command to execute. Supports pipes, redirection, and normal shell syntax."),
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."),
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.'),
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."),
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.")
34515
+ },
34516
+ execute: async (args, context) => {
34517
+ const bashCfg = resolveBashConfig(ctx.config);
34518
+ const ctxAftSearchRegistered = ctx.aftSearchRegistered === true;
34519
+ const aftSearchRegistered = aftSearchRegisteredOverride ?? ctxAftSearchRegistered;
34520
+ let accumulatedOutput = "";
34521
+ const description = args.description;
34522
+ const metadata = context.metadata;
34523
+ const rawCommand = args.command;
34524
+ const compressionEnabled = bashCfg.compress && args.compressed !== false;
34525
+ const pipeStrip = maybeStripCompressorPipe(rawCommand, compressionEnabled);
34526
+ const command = pipeStrip.command;
34527
+ const cwd = args.workdir ?? context.directory;
34528
+ const isSubagent = await resolveIsSubagent(ctx.client, context.sessionID, context.directory);
34529
+ const requestedPty = args.pty === true;
34530
+ const requestedBackground = args.background === true || requestedPty;
34531
+ if (requestedPty && isSubagent) {
34532
+ throw new Error("PTY mode is not available in subagent sessions; subagents cannot drive interactive terminals.");
34533
+ }
34534
+ const allowSubagentBg = bashCfg.subagent_background;
34535
+ const subagentForcedForeground = isSubagent && !allowSubagentBg;
34536
+ const effectiveBackground = subagentForcedForeground ? false : requestedBackground;
34537
+ const rawTimeout = args.timeout;
34538
+ const effectiveTimeout = effectiveBackground ? rawTimeout : resolveBashKillTimeout(rawTimeout, bashCfg.foreground_wait_window_ms);
34539
+ if (subagentForcedForeground && requestedBackground) {
34540
+ sessionLog(context.sessionID, "[bash] subagent + background:true → converting to foreground (subagent would lose task_id)");
34541
+ }
34542
+ const shellEnv = await ctx.plugin?.trigger?.("shell.env", { cwd, sessionID: context.sessionID, callID: getCallID(context) }, { env: {} });
34543
+ const data = await withPermissionLoop(ctx, context, {
34544
+ command,
34545
+ timeout: effectiveTimeout,
34546
+ workdir: args.workdir,
34547
+ env: shellEnv?.env ?? {},
34548
+ description,
34549
+ background: effectiveBackground,
34550
+ notify_on_completion: effectiveBackground,
34551
+ compressed: args.compressed,
34552
+ pty: requestedPty,
34553
+ pty_rows: args.ptyRows,
34554
+ pty_cols: args.ptyCols,
34555
+ permissions_requested: true
34556
+ }, callBashBridge, {
34557
+ onProgress: ({ text }) => {
34558
+ accumulatedOutput = preview(accumulatedOutput + text);
34559
+ metadata?.({ output: accumulatedOutput, description });
34560
+ }
33966
34561
  });
33967
- i = nextIdx;
33968
- } else {
33969
- i++;
34562
+ if (data.success === false) {
34563
+ throw new Error(data.message || "bash failed");
34564
+ }
34565
+ if (data.status === "running" && typeof data.task_id === "string") {
34566
+ const taskId = data.task_id;
34567
+ const uiTitle = description ?? shortenCommand(command);
34568
+ if (effectiveBackground) {
34569
+ trackBgTask(context.sessionID, taskId);
34570
+ let startedLine = formatBackgroundLaunch(taskId, requestedPty);
34571
+ if (isSubagent && allowSubagentBg)
34572
+ startedLine += subagentGuidance(taskId);
34573
+ startedLine = appendPipeStripNote(startedLine, pipeStrip.note);
34574
+ const metadataPayload2 = { description, output: startedLine, status: "running", taskId };
34575
+ metadata?.(metadataPayload2);
34576
+ return { output: startedLine, title: uiTitle, metadata: metadataPayload2 };
34577
+ }
34578
+ const foregroundWaitMs = resolveForegroundWaitMs(bashCfg.foreground_wait_window_ms);
34579
+ const waitTimeoutMs = subagentForcedForeground ? effectiveTimeout ?? DEFAULT_HARD_TIMEOUT_MS : effectiveTimeout !== undefined ? Math.min(effectiveTimeout, foregroundWaitMs) : foregroundWaitMs;
34580
+ const startedAt = Date.now();
34581
+ while (true) {
34582
+ const status = await callBashBridge(ctx, context, "bash_status", { task_id: taskId });
34583
+ if (status.success === false) {
34584
+ throw new Error(status.message ?? "bash_status failed");
34585
+ }
34586
+ if (isTerminalStatus(status.status)) {
34587
+ const rendered2 = maybeAppendGrepSearchHint(appendPipeStripNote(formatForegroundResult(status), pipeStrip.note), command, aftSearchRegistered);
34588
+ const metadataPayload2 = foregroundMetadata(description, status, rendered2);
34589
+ metadata?.(metadataPayload2);
34590
+ return { output: rendered2, title: uiTitle, metadata: metadataPayload2 };
34591
+ }
34592
+ if (Date.now() - startedAt >= waitTimeoutMs) {
34593
+ if (subagentForcedForeground) {
34594
+ await sleep(FOREGROUND_POLL_INTERVAL_MS);
34595
+ continue;
34596
+ }
34597
+ const promoted = await callBashBridge(ctx, context, "bash_promote", {
34598
+ task_id: taskId
34599
+ });
34600
+ if (promoted.success === false) {
34601
+ throw new Error(promoted.message ?? "bash_promote failed");
34602
+ }
34603
+ trackBgTask(context.sessionID, taskId);
34604
+ let message = formatPromotionMessage(taskId, effectiveTimeout, foregroundWaitMs);
34605
+ if (isSubagent && allowSubagentBg)
34606
+ message += subagentGuidance(taskId);
34607
+ message = appendPipeStripNote(message, pipeStrip.note);
34608
+ const metadataPayload2 = { description, output: message, status: "running", taskId };
34609
+ metadata?.(metadataPayload2);
34610
+ return { output: message, title: uiTitle, metadata: metadataPayload2 };
34611
+ }
34612
+ await sleep(FOREGROUND_POLL_INTERVAL_MS);
34613
+ }
34614
+ }
34615
+ const output = data.output ?? "";
34616
+ const metadataOutput = preview(output);
34617
+ const exit = data.exit_code;
34618
+ const truncated = data.truncated;
34619
+ const outputPath = data.output_path;
34620
+ const timedOut = data.timed_out === true;
34621
+ const metadataPayload = {
34622
+ description,
34623
+ output: metadataOutput,
34624
+ exit,
34625
+ truncated,
34626
+ ...outputPath ? { outputPath } : {}
34627
+ };
34628
+ metadata?.(metadataPayload);
34629
+ let rendered = output;
34630
+ if (truncated && outputPath) {
34631
+ rendered += `
34632
+ [output truncated; full output at ${outputPath}]`;
34633
+ }
34634
+ if (timedOut) {
34635
+ rendered += `
34636
+ [command timed out]`;
34637
+ }
34638
+ if (typeof exit === "number" && exit !== 0) {
34639
+ rendered += `
34640
+ [exit code: ${exit}]`;
34641
+ }
34642
+ rendered = appendPipeStripNote(rendered, pipeStrip.note);
34643
+ return {
34644
+ output: rendered,
34645
+ title: description ?? shortenCommand(command),
34646
+ metadata: metadataPayload
34647
+ };
33970
34648
  }
33971
- }
33972
- return hunks;
34649
+ };
33973
34650
  }
33974
- function normalizeUnicode(str) {
33975
- 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, " ");
34651
+ function appendPipeStripNote(output, note) {
34652
+ return note ? `${output}
34653
+
34654
+ ${note}` : output;
33976
34655
  }
33977
- function normalizeIndent(str) {
33978
- return str.replace(/^[\t ]+/, (m) => " ".repeat(m.length));
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
+ };
33979
34670
  }
33980
- function tryMatch(lines, pattern, startIndex, compare, eof) {
33981
- if (eof) {
33982
- const fromEnd = lines.length - pattern.length;
33983
- if (fromEnd >= startIndex) {
33984
- let matches = true;
33985
- for (let j = 0;j < pattern.length; j++) {
33986
- if (!compare(lines[fromEnd + j], pattern[j])) {
33987
- matches = false;
33988
- break;
33989
- }
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");
33990
34683
  }
33991
- if (matches)
33992
- return fromEnd;
33993
- }
33994
- }
33995
- for (let i = startIndex;i <= lines.length - pattern.length; i++) {
33996
- let matches = true;
33997
- for (let j = 0;j < pattern.length; j++) {
33998
- if (!compare(lines[i + j], pattern[j])) {
33999
- matches = false;
34000
- break;
34684
+ await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34685
+ if (data.kill_signaled === true) {
34686
+ return `Task ${args.taskId}: kill_signaled`;
34001
34687
  }
34688
+ return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34002
34689
  }
34003
- if (matches)
34004
- return i;
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");
34005
34696
  }
34006
- return -1;
34697
+ return data;
34007
34698
  }
34008
- function seekSequenceTiered(lines, pattern, startIndex, eof = false) {
34009
- if (pattern.length === 0)
34010
- return null;
34011
- const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
34012
- if (exact !== -1)
34013
- return { found: exact, tier: "exact" };
34014
- const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
34015
- if (rstrip !== -1)
34016
- return { found: rstrip, tier: "rstrip" };
34017
- const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
34018
- if (trim !== -1)
34019
- return { found: trim, tier: "trim" };
34020
- const indent = tryMatch(lines, pattern, startIndex, (a, b) => normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd(), eof);
34021
- if (indent !== -1)
34022
- return { found: indent, tier: "indent" };
34023
- const unicode = tryMatch(lines, pattern, startIndex, (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), eof);
34024
- if (unicode !== -1)
34025
- return { found: unicode, tier: "unicode" };
34026
- return null;
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;
34027
34718
  }
34028
- function seekSequence(lines, pattern, startIndex, eof = false) {
34029
- const r = seekSequenceTiered(lines, pattern, startIndex, eof);
34030
- return r ? r.found : -1;
34031
- }
34032
- function findClosestPartialMatch(lines, pattern) {
34033
- if (pattern.length === 0 || lines.length === 0)
34034
- return null;
34035
- 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());
34036
- const candidates = [];
34037
- for (let i = 0;i < lines.length && candidates.length < 16; i++) {
34038
- if (compareAny(lines[i], pattern[0]))
34039
- candidates.push(i);
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}` : "";
34040
34740
  }
34041
- if (candidates.length === 0)
34042
- return null;
34043
- let best = { lineNumber: -1, matchedLines: 0, firstDivergence: -1 };
34044
- for (const start of candidates) {
34045
- let matched = 0;
34046
- for (let j = 0;j < pattern.length && start + j < lines.length; j++) {
34047
- if (!compareAny(lines[start + j], pattern[j]))
34048
- break;
34049
- matched++;
34050
- }
34051
- if (matched > best.matchedLines) {
34052
- best = {
34053
- lineNumber: start + 1,
34054
- matchedLines: matched,
34055
- firstDivergence: matched
34056
- };
34057
- }
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);
34058
34746
  }
34059
- return best.lineNumber === -1 ? null : best;
34747
+ return suffix;
34060
34748
  }
34061
- function applyUpdateChunks(originalContent, filePath, chunks) {
34062
- const originalLines = originalContent.split(`
34063
- `);
34064
- if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34065
- originalLines.pop();
34066
- }
34067
- const replacements = [];
34068
- let lineIndex = 0;
34069
- for (const chunk of chunks) {
34070
- if (chunk.change_context) {
34071
- const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
34072
- if (contextIdx === -1) {
34073
- throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
34074
- }
34075
- lineIndex = contextIdx + 1;
34076
- }
34077
- if (chunk.old_lines.length === 0) {
34078
- let insertionIdx;
34079
- if (chunk.change_context) {
34080
- insertionIdx = lineIndex;
34081
- } else if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34082
- insertionIdx = originalLines.length - 1;
34083
- } else {
34084
- insertionIdx = originalLines.length;
34085
- }
34086
- replacements.push([insertionIdx, 0, chunk.new_lines]);
34087
- continue;
34088
- }
34089
- let pattern = chunk.old_lines;
34090
- let newSlice = chunk.new_lines;
34091
- let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34092
- if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
34093
- pattern = pattern.slice(0, -1);
34094
- if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
34095
- newSlice = newSlice.slice(0, -1);
34096
- }
34097
- found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34098
- }
34099
- if (found !== -1) {
34100
- replacements.push([found, pattern.length, newSlice]);
34101
- lineIndex = found + pattern.length;
34102
- } else {
34103
- const newSliceTrimmed = newSlice.filter((line) => line.trim().length > 0);
34104
- const alreadyApplied = newSliceTrimmed.length > 0 && seekSequence(originalLines, newSliceTrimmed, 0, chunk.is_end_of_file) !== -1;
34105
- const closest = findClosestPartialMatch(originalLines, pattern);
34106
- let closestHint = "";
34107
- if (closest && closest.matchedLines > 0) {
34108
- const fileLineNo = closest.lineNumber + closest.firstDivergence;
34109
- const expectedLine = pattern[closest.firstDivergence];
34110
- const actualLine = fileLineNo - 1 < originalLines.length ? originalLines[fileLineNo - 1] : "<EOF>";
34111
- closestHint = `
34112
-
34113
- Closest match starts at line ${closest.lineNumber} ` + `(${closest.matchedLines} of ${pattern.length} lines matched).
34114
- ` + `First divergence at line ${fileLineNo}:
34115
- ` + ` expected: ${JSON.stringify(expectedLine)}
34116
- ` + ` actual: ${JSON.stringify(actualLine)}`;
34117
- }
34118
- const triedTiers = "exact, trimEnd, trim, indent (tab/space), unicode";
34119
- const alreadyAppliedHint = alreadyApplied ? `
34120
-
34121
- 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." : "";
34122
- throw new Error(`Failed to find expected lines in ${filePath}:
34123
- ${chunk.old_lines.join(`
34124
- `)}
34125
-
34126
- ` + `Tried match tiers: ${triedTiers}.${closestHint}${alreadyAppliedHint}`);
34127
- }
34128
- }
34129
- replacements.sort((a, b) => a[0] - b[0]);
34130
- const result = [...originalLines];
34131
- for (let i = replacements.length - 1;i >= 0; i--) {
34132
- const [startIdx, oldLen, newSegment] = replacements[i];
34133
- result.splice(startIdx, oldLen, ...newSegment);
34134
- }
34135
- if (result.length === 0 || result[result.length - 1] !== "") {
34136
- result.push("");
34137
- }
34138
- return result.join(`
34139
- `);
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 };
34140
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 `
34141
34765
 
34142
- // src/tools/bash.ts
34143
- import { tool as tool5 } from "@opencode-ai/plugin";
34144
-
34145
- // src/shared/subagent-detect.ts
34146
- var CACHE_MAX_ENTRIES2 = 200;
34147
- var cache2 = new Map;
34148
- async function resolveIsSubagent(client, sessionId, _fallbackDirectory) {
34149
- if (!sessionId) {
34150
- sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
34151
- return false;
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.`;
34152
34771
  }
34153
- const cached2 = cache2.get(sessionId);
34154
- if (cached2) {
34155
- cache2.delete(sessionId);
34156
- cache2.set(sessionId, cached2);
34157
- return cached2.isSubagent;
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}]`;
34158
34791
  }
34159
- const c = client;
34160
- const sessionApi = c?.session;
34161
- if (!sessionApi || typeof sessionApi.get !== "function") {
34162
- sessionLog(sessionId, `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`);
34163
- setCache2(sessionId, false);
34164
- return false;
34792
+ if (status === "timed_out") {
34793
+ rendered += `
34794
+ [command timed out]`;
34165
34795
  }
34166
- sessionLog(sessionId, `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`);
34167
- let isSubagent = false;
34168
- let parentIdRaw;
34169
- try {
34170
- const result = await sessionApi.get({
34171
- path: { id: sessionId }
34172
- });
34173
- const session = result?.data ?? result;
34174
- parentIdRaw = session?.parentID;
34175
- isSubagent = session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
34176
- sessionLog(sessionId, `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`);
34177
- } catch (err) {
34178
- sessionWarn(sessionId, `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`);
34179
- return false;
34796
+ if (typeof exit === "number" && exit !== 0) {
34797
+ rendered += `
34798
+ [exit code: ${exit}]`;
34180
34799
  }
34181
- setCache2(sessionId, isSubagent);
34182
- return isSubagent;
34800
+ return rendered;
34183
34801
  }
34184
- function setCache2(sessionId, isSubagent) {
34185
- if (cache2.has(sessionId))
34186
- cache2.delete(sessionId);
34187
- cache2.set(sessionId, { isSubagent, recordedAt: Date.now() });
34188
- if (cache2.size > CACHE_MAX_ENTRIES2) {
34189
- const oldest = cache2.keys().next().value;
34190
- if (oldest !== undefined)
34191
- cache2.delete(oldest);
34192
- }
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)}...`;
34193
34822
  }
34194
34823
 
34195
- // src/tools/bash.ts
34824
+ // src/tools/conflicts.ts
34825
+ import { tool as tool5 } from "@opencode-ai/plugin";
34196
34826
  var z5 = tool5.schema;
34197
- var METADATA_PREVIEW_LIMIT = 30 * 1024;
34198
- var FOREGROUND_POLL_INTERVAL_MS = 100;
34199
- var DEFAULT_HARD_TIMEOUT_MS = 30 * 60 * 1000;
34200
- function resolveForegroundWaitMs(configured) {
34201
- const override = process.env.AFT_TEST_FOREGROUND_WAIT_MS;
34202
- if (override !== undefined) {
34203
- const parsed = Number(override);
34204
- if (Number.isFinite(parsed) && parsed >= 0)
34205
- return parsed;
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
+ }
34853
+ }
34854
+ };
34855
+ }
34856
+
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;
34866
+ }
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;
34206
34872
  }
34207
- return configured;
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++;
34884
+ }
34885
+ return filePath ? { filePath, movePath, nextIdx } : null;
34886
+ }
34887
+ return null;
34208
34888
  }
34209
- 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.`;
34210
- async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34211
- const first = await bridgeCall(ctx, runtime, "bash", params, options);
34212
- if (first.success !== false || first.code !== "permission_required")
34213
- return first;
34214
- const asks = Array.isArray(first.asks) ? first.asks : [];
34215
- const permissionsGranted = [];
34216
- for (const ask of asks) {
34217
- const permission = ask.kind === "external_directory" ? "external_directory" : "bash";
34218
- await runAsk(runtime.ask({
34219
- permission,
34220
- patterns: ask.patterns,
34221
- always: ask.always,
34222
- metadata: {}
34223
- }));
34224
- permissionsGranted.push(...ask.always.length > 0 ? ask.always : ask.patterns);
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++;
34225
34898
  }
34226
- const second = await bridgeCall(ctx, runtime, "bash", { ...params, permissions_granted: permissionsGranted }, options);
34227
- if (second.success === false && second.code === "permission_required") {
34228
- throw new Error("bash permission retry failed");
34899
+ if (content.endsWith(`
34900
+ `)) {
34901
+ content = content.slice(0, -1);
34229
34902
  }
34230
- return second;
34903
+ return { content, nextIdx: i };
34231
34904
  }
34232
- function createBashTool(ctx) {
34233
- return {
34234
- description: BASH_DESCRIPTION,
34235
- args: {
34236
- 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."),
34237
- 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."),
34238
- 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."),
34239
- description: z5.string().optional().describe("Short 5-10 word human-readable summary shown in OpenCode UI metadata instead of raw shell syntax."),
34240
- 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."),
34241
- compressed: z5.boolean().optional().describe("When true or omitted, return compressed output with noisy terminal control sequences reduced. Set to false for raw output."),
34242
- 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.'),
34243
- 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."),
34244
- 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.")
34245
- },
34246
- execute: async (args, context) => {
34247
- const bashCfg = resolveBashConfig(ctx.config);
34248
- let accumulatedOutput = "";
34249
- const description = args.description;
34250
- const metadata = context.metadata;
34251
- const rawCommand = args.command;
34252
- const compressionEnabled = bashCfg.compress && args.compressed !== false;
34253
- const pipeStrip = maybeStripCompressorPipe(rawCommand, compressionEnabled);
34254
- const command = pipeStrip.command;
34255
- const cwd = args.workdir ?? context.directory;
34256
- const isSubagent = await resolveIsSubagent(ctx.client, context.sessionID, context.directory);
34257
- const requestedPty = args.pty === true;
34258
- const requestedBackground = args.background === true || requestedPty;
34259
- if (requestedPty && isSubagent) {
34260
- throw new Error("PTY mode is not available in subagent sessions; subagents cannot drive interactive terminals.");
34261
- }
34262
- const allowSubagentBg = bashCfg.subagent_background;
34263
- const subagentForcedForeground = isSubagent && !allowSubagentBg;
34264
- const effectiveBackground = subagentForcedForeground ? false : requestedBackground;
34265
- const rawTimeout = args.timeout;
34266
- const effectiveTimeout = effectiveBackground ? rawTimeout : resolveBashKillTimeout(rawTimeout, bashCfg.foreground_wait_window_ms);
34267
- if (subagentForcedForeground && requestedBackground) {
34268
- sessionLog(context.sessionID, "[bash] subagent + background:true → converting to foreground (subagent would lose task_id)");
34269
- }
34270
- const shellEnv = await ctx.plugin?.trigger?.("shell.env", { cwd, sessionID: context.sessionID, callID: getCallID(context) }, { env: {} });
34271
- const data = await withPermissionLoop(ctx, context, {
34272
- command,
34273
- timeout: effectiveTimeout,
34274
- workdir: args.workdir,
34275
- env: shellEnv?.env ?? {},
34276
- description,
34277
- background: effectiveBackground,
34278
- notify_on_completion: effectiveBackground,
34279
- compressed: args.compressed,
34280
- pty: requestedPty,
34281
- pty_rows: args.ptyRows,
34282
- pty_cols: args.ptyCols,
34283
- permissions_requested: true
34284
- }, callBashBridge, {
34285
- onProgress: ({ text }) => {
34286
- accumulatedOutput = preview(accumulatedOutput + text);
34287
- metadata?.({ output: accumulatedOutput, description });
34288
- }
34289
- });
34290
- if (data.success === false) {
34291
- throw new Error(data.message || "bash failed");
34292
- }
34293
- if (data.status === "running" && typeof data.task_id === "string") {
34294
- const callID2 = getCallID(context);
34295
- const taskId = data.task_id;
34296
- if (effectiveBackground) {
34297
- trackBgTask(context.sessionID, taskId);
34298
- let startedLine = formatBackgroundLaunch(taskId, requestedPty);
34299
- if (isSubagent && allowSubagentBg)
34300
- startedLine += subagentGuidance(taskId);
34301
- const metadataPayload2 = { description, output: startedLine, status: "running", taskId };
34302
- metadata?.(metadataPayload2);
34303
- if (callID2) {
34304
- storeToolMetadata(context.sessionID, callID2, {
34305
- title: description ?? shortenCommand(command),
34306
- metadata: metadataPayload2
34307
- });
34308
- }
34309
- return startedLine;
34310
- }
34311
- const foregroundWaitMs = resolveForegroundWaitMs(bashCfg.foreground_wait_window_ms);
34312
- const waitTimeoutMs = subagentForcedForeground ? effectiveTimeout ?? DEFAULT_HARD_TIMEOUT_MS : effectiveTimeout !== undefined ? Math.min(effectiveTimeout, foregroundWaitMs) : foregroundWaitMs;
34313
- const startedAt = Date.now();
34314
- while (true) {
34315
- const status = await callBashBridge(ctx, context, "bash_status", { task_id: taskId });
34316
- if (status.success === false) {
34317
- throw new Error(status.message ?? "bash_status failed");
34318
- }
34319
- if (isTerminalStatus(status.status)) {
34320
- const rendered2 = appendPipeStripNote(formatForegroundResult(status), pipeStrip.note);
34321
- const metadataPayload2 = foregroundMetadata(description, status, rendered2);
34322
- metadata?.(metadataPayload2);
34323
- if (callID2) {
34324
- storeToolMetadata(context.sessionID, callID2, {
34325
- title: description ?? shortenCommand(command),
34326
- metadata: metadataPayload2
34327
- });
34328
- }
34329
- return rendered2;
34330
- }
34331
- if (Date.now() - startedAt >= waitTimeoutMs) {
34332
- if (subagentForcedForeground) {
34333
- await sleep(FOREGROUND_POLL_INTERVAL_MS);
34334
- continue;
34335
- }
34336
- const promoted = await callBashBridge(ctx, context, "bash_promote", {
34337
- task_id: taskId
34338
- });
34339
- if (promoted.success === false) {
34340
- throw new Error(promoted.message ?? "bash_promote failed");
34341
- }
34342
- trackBgTask(context.sessionID, taskId);
34343
- let message = formatPromotionMessage(taskId, effectiveTimeout, foregroundWaitMs);
34344
- if (isSubagent && allowSubagentBg)
34345
- message += subagentGuidance(taskId);
34346
- const metadataPayload2 = { description, output: message, status: "running", taskId };
34347
- metadata?.(metadataPayload2);
34348
- if (callID2) {
34349
- storeToolMetadata(context.sessionID, callID2, {
34350
- title: description ?? shortenCommand(command),
34351
- metadata: metadataPayload2
34352
- });
34353
- }
34354
- return message;
34355
- }
34356
- await sleep(FOREGROUND_POLL_INTERVAL_MS);
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;
34357
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++;
34358
34932
  }
34359
- const output = data.output ?? "";
34360
- const metadataOutput = preview(output);
34361
- const exit = data.exit_code;
34362
- const truncated = data.truncated;
34363
- const outputPath = data.output_path;
34364
- const timedOut = data.timed_out === true;
34365
- const callID = getCallID(context);
34366
- const metadataPayload = {
34367
- description,
34368
- output: metadataOutput,
34369
- exit,
34370
- truncated,
34371
- ...outputPath ? { outputPath } : {}
34372
- };
34373
- metadata?.(metadataPayload);
34374
- if (callID) {
34375
- storeToolMetadata(context.sessionID, callID, {
34376
- title: description ?? shortenCommand(command),
34377
- metadata: metadataPayload
34378
- });
34379
- }
34380
- let rendered = output;
34381
- if (truncated && outputPath) {
34382
- rendered += `
34383
- [output truncated; full output at ${outputPath}]`;
34384
- }
34385
- if (timedOut) {
34386
- rendered += `
34387
- [command timed out]`;
34388
- }
34389
- if (typeof exit === "number" && exit !== 0) {
34390
- rendered += `
34391
- [exit code: ${exit}]`;
34392
- }
34393
- rendered = appendPipeStripNote(rendered, pipeStrip.note);
34394
- return rendered;
34395
- }
34396
- };
34397
- }
34398
- function appendPipeStripNote(output, note) {
34399
- return note ? `${output}
34400
-
34401
- ${note}` : output;
34402
- }
34403
- function createBashStatusTool(ctx) {
34404
- return {
34405
- 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.",
34406
- args: {
34407
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047."),
34408
- 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.")
34409
- },
34410
- execute: async (args, context) => {
34411
- const taskId = args.taskId;
34412
- const outputMode = args.outputMode;
34413
- const data = await bashStatusSnapshot(ctx, context, taskId, outputMode);
34414
- return await formatBashStatusText(context, taskId, data, outputMode);
34415
- }
34416
- };
34417
- }
34418
- function createBashKillTool(ctx) {
34419
- return {
34420
- description: "Terminate a running background bash task spawned with bash({ background: true }). Returns confirmation of kill or an error if the task already finished.",
34421
- args: {
34422
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047.")
34423
- },
34424
- execute: async (args, context) => {
34425
- const data = await callBashBridge(ctx, context, "bash_kill", {
34426
- task_id: args.taskId
34933
+ chunks.push({
34934
+ old_lines: oldLines,
34935
+ new_lines: newLines,
34936
+ change_context: contextLine || undefined,
34937
+ is_end_of_file: isEndOfFile || undefined
34427
34938
  });
34428
- if (data.success === false) {
34429
- throw new Error(data.message ?? "bash_kill failed");
34430
- }
34431
- await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34432
- if (data.kill_signaled === true) {
34433
- return `Task ${args.taskId}: kill_signaled`;
34434
- }
34435
- return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34939
+ } else {
34940
+ i++;
34436
34941
  }
34437
- };
34438
- }
34439
- async function bashStatusSnapshot(ctx, runtime, taskId, outputMode, options) {
34440
- const data = await callBashBridge(ctx, runtime, "bash_status", { task_id: taskId, output_mode: outputMode }, options);
34441
- if (data.success === false) {
34442
- throw new Error(data.message ?? "bash_status failed");
34443
34942
  }
34444
- return data;
34943
+ return { chunks, nextIdx: i };
34445
34944
  }
34446
- async function formatBashStatusText(runtime, taskId, data, requestedOutputMode) {
34447
- const status = data.status;
34448
- const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
34449
- const dur = typeof data.duration_ms === "number" ? ` ${Math.round(data.duration_ms / 1000)}s` : "";
34450
- let text = `Task ${taskId}: ${status}${exit}${dur}`;
34451
- if (data.mode === "pty") {
34452
- text += await formatPtyStatus(runtime, taskId, data, requestedOutputMode);
34453
- } else {
34454
- const preview = data.output_preview;
34455
- if (preview && status !== "running") {
34456
- text += `
34457
- ${preview}`;
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;
34458
34966
  }
34459
- if (status === "running") {
34460
- text += `
34461
- A completion reminder will be delivered automatically; don't poll.`;
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++;
34462
34988
  }
34463
34989
  }
34464
- return text;
34990
+ return hunks;
34465
34991
  }
34466
- async function formatPtyStatus(runtime, taskId, data, requestedOutputMode) {
34467
- const outputPath = data.output_path;
34468
- if (!outputPath)
34469
- return `
34470
- [PTY output path unavailable]`;
34471
- const key = ptyCacheKey(runtime, taskId);
34472
- const { rows, cols } = ptyDimensions(data);
34473
- const state = await getOrCreatePtyTerminal(key, outputPath, rows, cols);
34474
- const raw = await readPtyBytes(state);
34475
- const outputMode = requestedOutputMode ?? "screen";
34476
- let suffix = "";
34477
- if (outputMode === "raw") {
34478
- suffix = raw.length > 0 ? `
34479
- ${raw.toString("utf8")}` : "";
34480
- } else if (outputMode === "both") {
34481
- suffix = `
34482
- ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
34483
- } else {
34484
- const screen = renderScreen(state, rows, cols);
34485
- suffix = screen ? `
34486
- ${screen}` : "";
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
+ }
35008
+ }
35009
+ if (matches)
35010
+ return fromEnd;
35011
+ }
34487
35012
  }
34488
- if (data.status === "running") {
34489
- suffix += `
34490
- PTY task is still running. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to inspect, bash_write({ taskId: "${taskId}", input: "..." }) to send keystrokes.`;
34491
- } else if (isTerminalStatus(data.status)) {
34492
- await disposePtyTerminal(key);
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;
35019
+ }
35020
+ }
35021
+ if (matches)
35022
+ return i;
34493
35023
  }
34494
- return suffix;
34495
- }
34496
- function ptyDimensions(data) {
34497
- const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
34498
- const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
34499
- return { rows, cols };
35024
+ return -1;
34500
35025
  }
34501
- function ptyCacheKey(runtime, taskId) {
34502
- return `${projectRootFor(runtime)}::${runtime.sessionID ?? "__default__"}::${taskId}`;
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;
34503
35045
  }
34504
- function preview(output) {
34505
- return output.length <= METADATA_PREVIEW_LIMIT ? output : output.slice(-METADATA_PREVIEW_LIMIT);
35046
+ function seekSequence(lines, pattern, startIndex, eof = false) {
35047
+ const r = seekSequenceTiered(lines, pattern, startIndex, eof);
35048
+ return r ? r.found : -1;
34506
35049
  }
34507
- function isTerminalStatus(status) {
34508
- return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
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++;
35068
+ }
35069
+ if (matched > best.matchedLines) {
35070
+ best = {
35071
+ lineNumber: start + 1,
35072
+ matchedLines: matched,
35073
+ firstDivergence: matched
35074
+ };
35075
+ }
35076
+ }
35077
+ return best.lineNumber === -1 ? null : best;
34509
35078
  }
34510
- function subagentGuidance(taskId) {
34511
- return `
35079
+ function applyUpdateChunks(originalContent, filePath, chunks) {
35080
+ const originalLines = originalContent.split(`
35081
+ `);
35082
+ if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
35083
+ originalLines.pop();
35084
+ }
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 = `
34512
35130
 
34513
- 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.`;
34514
- }
34515
- function formatBackgroundLaunch(taskId, isPty) {
34516
- if (isPty) {
34517
- 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.`;
34518
- }
34519
- return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
34520
- }
34521
- function formatPromotionMessage(taskId, timeout, waitWindowMs) {
34522
- const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
34523
- 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.`;
34524
- }
34525
- function formatSeconds(ms) {
34526
- return `${Number((ms / 1000).toFixed(1))}s`;
34527
- }
34528
- function formatForegroundResult(data) {
34529
- const output = data.output_preview ?? "";
34530
- const outputPath = data.output_path;
34531
- const truncated = data.output_truncated === true;
34532
- const status = data.status;
34533
- const exit = data.exit_code;
34534
- let rendered = output;
34535
- if (truncated && outputPath) {
34536
- rendered += `
34537
- [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
+ }
34538
35146
  }
34539
- if (status === "timed_out") {
34540
- rendered += `
34541
- [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);
34542
35152
  }
34543
- if (typeof exit === "number" && exit !== 0) {
34544
- rendered += `
34545
- [exit code: ${exit}]`;
35153
+ if (result.length === 0 || result[result.length - 1] !== "") {
35154
+ result.push("");
34546
35155
  }
34547
- return rendered;
34548
- }
34549
- function foregroundMetadata(description, data, rendered) {
34550
- const outputPath = data.output_path;
34551
- return {
34552
- description,
34553
- output: preview(rendered),
34554
- exit: data.exit_code,
34555
- truncated: data.output_truncated,
34556
- ...outputPath ? { outputPath } : {}
34557
- };
34558
- }
34559
- function sleep(ms) {
34560
- return new Promise((resolve7) => setTimeout(resolve7, ms));
34561
- }
34562
- function getCallID(ctx) {
34563
- const c = ctx;
34564
- return c.callID ?? c.callId ?? c.call_id;
34565
- }
34566
- function shortenCommand(command) {
34567
- const collapsed = command.replace(/\s+/g, " ").trim();
34568
- return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 77)}...`;
35156
+ return result.join(`
35157
+ `);
34569
35158
  }
34570
35159
 
34571
35160
  // src/tools/bash_watch.ts
@@ -34874,10 +35463,6 @@ function createBashWriteTool(ctx) {
34874
35463
  }
34875
35464
 
34876
35465
  // src/tools/hoisted.ts
34877
- function getCallID2(ctx) {
34878
- const c = ctx;
34879
- return c.callID ?? c.callId ?? c.call_id;
34880
- }
34881
35466
  function relativeToWorktree(fp, worktree) {
34882
35467
  return path4.relative(worktree, fp);
34883
35468
  }
@@ -35083,7 +35668,6 @@ function inferBeforeStart(ops, from, beforeLen) {
35083
35668
  return beforeLen;
35084
35669
  }
35085
35670
  var z8 = tool8.schema;
35086
- 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.";
35087
35671
  function diagnosticsOnEditDefault(ctx) {
35088
35672
  return ctx.config.lsp?.diagnostics_on_edit ?? false;
35089
35673
  }
@@ -35163,19 +35747,16 @@ function createReadTool(ctx) {
35163
35747
  } catch {}
35164
35748
  const sizeStr = fileSize > 1048576 ? `${(fileSize / 1048576).toFixed(1)}MB` : fileSize > 1024 ? `${(fileSize / 1024).toFixed(0)}KB` : `${fileSize} bytes`;
35165
35749
  const msg = `${label} read successfully`;
35166
- const imgCallID = getCallID2(context);
35167
- if (imgCallID) {
35168
- storeToolMetadata(context.sessionID, imgCallID, {
35169
- title: path4.relative(projectRoot, filePath),
35170
- metadata: {
35171
- preview: msg,
35172
- filepath: filePath,
35173
- isImage,
35174
- isPdf: mime === "application/pdf"
35175
- }
35176
- });
35177
- }
35178
- return `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`;
35750
+ return {
35751
+ output: `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`,
35752
+ title: path4.relative(projectRoot, filePath),
35753
+ metadata: {
35754
+ preview: msg,
35755
+ filepath: filePath,
35756
+ isImage,
35757
+ isPdf: mime === "application/pdf"
35758
+ }
35759
+ };
35179
35760
  }
35180
35761
  let startLine = args.startLine;
35181
35762
  let endLine = args.endLine;
@@ -35196,60 +35777,36 @@ function createReadTool(ctx) {
35196
35777
  if (data.success === false) {
35197
35778
  throw new Error(data.message || "read failed");
35198
35779
  }
35199
- const readCallID = getCallID2(context);
35780
+ const dp = relativeToWorktree(filePath, projectRoot) || file2;
35200
35781
  if (data.entries) {
35201
- if (readCallID) {
35202
- const dp = relativeToWorktree(filePath, projectRoot) || file2;
35203
- storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
35204
- }
35205
- return data.entries.join(`
35206
- `);
35782
+ return {
35783
+ output: data.entries.join(`
35784
+ `),
35785
+ title: dp,
35786
+ metadata: { title: dp }
35787
+ };
35207
35788
  }
35208
35789
  if (data.binary) {
35209
- if (readCallID) {
35210
- const dp = relativeToWorktree(filePath, projectRoot) || file2;
35211
- storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
35212
- }
35213
- return data.message;
35214
- }
35215
- if (readCallID) {
35216
- const dp = relativeToWorktree(filePath, projectRoot) || file2;
35217
- storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
35790
+ return { output: data.message, title: dp, metadata: { title: dp } };
35218
35791
  }
35219
35792
  let output = data.content;
35220
35793
  const agentSpecifiedRange = args.startLine !== undefined || args.endLine !== undefined || args.offset !== undefined || args.limit !== undefined;
35221
35794
  const footer = formatReadFooter(agentSpecifiedRange, data);
35222
35795
  if (footer)
35223
35796
  output += footer;
35224
- return output;
35797
+ return { output, title: dp, metadata: { title: dp } };
35225
35798
  }
35226
35799
  };
35227
35800
  }
35228
35801
  function getWriteDescription(editToolName) {
35229
- return `Write content to a file, creating it (and parent directories) if needed.
35230
-
35231
- Automatically creates parent directories. Backs up existing files before overwriting.
35232
- If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
35233
- is auto-formatted after writing. Edits return as soon as the write completes unless
35234
- the configured \`lsp.diagnostics_on_edit\` default or a per-call \`diagnostics: true\`
35235
- asks for legacy sync-wait behavior. Call \`aft_inspect\` afterward to check
35236
- diagnostics across a batch of edits.
35237
-
35238
- **Behavior:**
35239
- - Creates parent directories automatically (no need to mkdir first)
35240
- - Existing files are backed up before overwriting (recoverable via aft_safety undo)
35241
- - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
35242
- - LSP diagnostics follow \`lsp.diagnostics_on_edit\` by default; pass \`diagnostics\` to override per call
35243
- - Use this for creating new files or completely replacing file contents
35244
- - 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.`;
35245
35803
  }
35246
35804
  function createWriteTool(ctx, editToolName = "edit") {
35247
35805
  return {
35248
35806
  description: getWriteDescription(editToolName),
35249
35807
  args: {
35250
35808
  filePath: z8.string().describe("Path to the file to write (absolute or relative to project root)"),
35251
- content: z8.string().describe("The full content to write to the file"),
35252
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
35809
+ content: z8.string().describe("The full content to write to the file")
35253
35810
  },
35254
35811
  execute: async (args, context) => {
35255
35812
  const file2 = args.filePath;
@@ -35272,12 +35829,15 @@ function createWriteTool(ctx, editToolName = "edit") {
35272
35829
  file: filePath,
35273
35830
  content,
35274
35831
  create_dirs: true,
35275
- diagnostics: args.diagnostics ?? diagnosticsOnEditDefault(ctx),
35832
+ diagnostics: diagnosticsOnEditDefault(ctx),
35276
35833
  include_diff_content: true
35277
35834
  });
35278
35835
  if (data.success === false) {
35279
35836
  throw new Error(data.message || "write failed");
35280
35837
  }
35838
+ if (data.rolled_back === true) {
35839
+ return "Write rolled back: the content produced invalid syntax, so the file was left unchanged.";
35840
+ }
35281
35841
  let output = data.created ? "Created new file." : "File updated.";
35282
35842
  if (data.formatted)
35283
35843
  output += " Auto-formatted.";
@@ -35311,27 +35871,24 @@ Note: LSP server(s) did not respond in time: ${pendingServers.join(", ")}. Diagn
35311
35871
  Note: LSP server(s) exited during this edit: ${exitedServers.join(", ")}. Their diagnostics could not be collected.`;
35312
35872
  }
35313
35873
  const diff = data.diff;
35314
- const callID = getCallID2(context);
35315
- if (callID) {
35316
- const dp = relativeToWorktree(filePath, projectRoot);
35317
- const beforeContent = diff?.before ?? "";
35318
- const afterContent = diff?.after ?? content;
35319
- storeToolMetadata(context.sessionID, callID, {
35320
- title: dp,
35321
- metadata: {
35322
- diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
35323
- filediff: {
35324
- file: filePath,
35325
- before: beforeContent,
35326
- after: afterContent,
35327
- additions: diff?.additions ?? 0,
35328
- deletions: diff?.deletions ?? 0
35329
- },
35330
- diagnostics: {}
35331
- }
35332
- });
35333
- }
35334
- return output;
35874
+ const dp = relativeToWorktree(filePath, projectRoot);
35875
+ const beforeContent = diff?.before ?? "";
35876
+ const afterContent = diff?.after ?? content;
35877
+ return {
35878
+ output,
35879
+ title: dp,
35880
+ metadata: {
35881
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
35882
+ filediff: {
35883
+ file: filePath,
35884
+ before: beforeContent,
35885
+ after: afterContent,
35886
+ additions: diff?.additions ?? 0,
35887
+ deletions: diff?.deletions ?? 0
35888
+ },
35889
+ diagnostics: {}
35890
+ }
35891
+ };
35335
35892
  }
35336
35893
  };
35337
35894
  }
@@ -35340,107 +35897,66 @@ function getEditDescription(writeToolName) {
35340
35897
 
35341
35898
  **Modes** (determined by which parameters you provide):
35342
35899
 
35343
- Mode priority: operations > appendContent > edits > symbol (without oldString) > oldString (find/replace). If none match, the call is rejected — there is no implicit "write" fallback.
35900
+ Mode priority: appendContent > edits > symbol (without oldString) > oldString (find/replace). If none match, the call is rejected — there is no implicit "write" fallback. To edit multiple files, make parallel \`edit\` calls in one response.
35344
35901
 
35345
- 1. **Multi-file transaction** — pass \`operations\` array
35346
- Edits across multiple files with checkpoint-based rollback on failure.
35347
- Each operation: \`{ "file": "path", "command": "edit_match" | "write", ... }\`.
35348
- For \`edit_match\`: include \`match\`, \`replacement\`. For \`write\`: include \`content\`.
35349
- Example: \`{ "operations": [{ "file": "a.ts", "command": "edit_match", "match": "old", "replacement": "new" }, { "file": "b.ts", "command": "write", "content": "..." }] }\`
35350
-
35351
- 2. **Append** — pass \`filePath\` + \`appendContent\`
35902
+ 1. **Append** — pass \`filePath\` + \`appendContent\`
35352
35903
  Appends text to the end of a file, creating the file if it does not exist.
35353
35904
  Example: \`{ "filePath": "notes.txt", "appendContent": "new line\\n" }\`
35354
35905
 
35355
- 3. **Batch edits** — pass \`filePath\` + \`edits\` array
35906
+ 2. **Batch edits** — pass \`filePath\` + \`edits\` array
35356
35907
  Multiple edits in one file atomically. Each edit is either:
35357
35908
  - \`{ "oldString": "old", "newString": "new" }\` — find/replace
35358
35909
  - \`{ "startLine": 5, "endLine": 7, "content": "new lines" }\` — replace line range (1-based, both inclusive)
35359
35910
  Set content to empty string to delete lines.
35360
35911
 
35361
- 4. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
35912
+ 3. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
35362
35913
  Replaces an entire named symbol (function, class, type) with new content.
35363
35914
  Includes decorators, attributes, and doc comments in the replacement range.
35364
35915
  **Important:** You must NOT provide \`oldString\` when using symbol mode — if present, the tool silently falls back to find/replace mode.
35365
35916
  Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest", "content": "function handleRequest() { ... }" }\`
35366
35917
 
35367
- 5. **Find and replace** — pass \`filePath\` + \`oldString\` + \`newString\`
35918
+ 4. **Find and replace** — pass \`filePath\` + \`oldString\` + \`newString\`
35368
35919
  Finds the exact text in \`oldString\` and replaces it with \`newString\`.
35369
35920
  Supports fuzzy matching (handles whitespace differences automatically).
35370
35921
  If multiple matches exist, specify which one with \`occurrence\` or use \`replaceAll: true\`.
35371
35922
  Example: \`{ "filePath": "src/app.ts", "oldString": "const x = 1", "newString": "const x = 2" }\`
35372
35923
 
35373
- 6. **Replace all occurrences** — add \`replaceAll: true\`
35924
+ 5. **Replace all occurrences** — add \`replaceAll: true\`
35374
35925
  Replaces every occurrence of \`oldString\` in the file.
35375
35926
  Example: \`{ "filePath": "src/app.ts", "oldString": "oldName", "newString": "newName", "replaceAll": true }\`
35376
35927
 
35377
- 7. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
35928
+ 6. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
35378
35929
  When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
35379
35930
  Example: \`{ "filePath": "src/app.ts", "oldString": "TODO", "newString": "DONE", "occurrence": 0 }\`
35380
35931
 
35381
- Note: Modes 6 and 7 are options on mode 5 (find/replace) — they require \`oldString\`.
35932
+ Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldString\`.
35382
35933
 
35383
35934
  **Behavior:**
35384
35935
  - Backs up files before editing (recoverable via aft_safety undo)
35385
35936
  - Auto-formats using project formatter if configured
35386
35937
  - Tree-sitter syntax validation on all edits
35387
35938
  - Symbol replace includes decorators, attributes, and doc comments in range
35388
- - 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.
35389
35939
  - Response is a JSON string for the selected edit mode; key fields include success, diff, backup_id, syntax_valid, and mode-specific fields.`;
35390
35940
  }
35391
35941
  function createEditTool(ctx, writeToolName = "write") {
35392
35942
  return {
35393
35943
  description: getEditDescription(writeToolName),
35394
35944
  args: {
35395
- filePath: z8.string().optional().describe("Path to the file to edit (absolute or relative to project root). Required for all modes except 'operations' multi-file transactions"),
35945
+ filePath: z8.string().optional().describe("Path to the file to edit (absolute or relative to project root)"),
35396
35946
  oldString: z8.string().optional().describe("Text to find (exact match, with fuzzy fallback)"),
35397
35947
  newString: z8.string().optional().describe("Text to replace with (omit or set to empty string to delete the matched text)"),
35398
35948
  replaceAll: z8.boolean().optional().describe("Replace all occurrences"),
35399
35949
  occurrence: optionalInt(0, Number.MAX_SAFE_INTEGER).describe("0-indexed occurrence to replace when multiple matches exist"),
35400
35950
  symbol: z8.string().optional().describe("Named symbol to replace (function, class, type)"),
35401
- content: z8.string().optional().describe("Replacement content for symbol mode or operations[].command='write'. For whole-file writes, use the `write` tool."),
35951
+ content: z8.string().optional().describe("Replacement content for symbol mode. For whole-file writes, use the `write` tool."),
35402
35952
  appendContent: z8.string().optional().describe("Text to append to the end of filePath; creates the file if needed"),
35403
- 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 }"),
35404
- operations: z8.array(z8.record(z8.string(), z8.unknown())).optional().describe("Transaction — array of { file: string, command: 'edit_match' | 'write', match?: string, replacement?: string, content?: string } for multi-file edits with rollback. Note: uses 'file'/'match'/'replacement' (not filePath/oldString/newString)"),
35405
- 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 }")
35406
35954
  },
35407
35955
  execute: async (args, context) => {
35408
35956
  const argsRecord = args;
35409
35957
  if (argsRecord.startLine !== undefined || argsRecord.endLine !== undefined) {
35410
35958
  throw new Error("edit: 'startLine'/'endLine' are not top-level parameters. For line-range edits, nest them inside the `edits` array: `edits: [{ startLine: N, endLine: M, content: \"...\" }]`. For find/replace, use `oldString`/`newString` instead.");
35411
35959
  }
35412
- if (Array.isArray(args.operations)) {
35413
- const ops = args.operations;
35414
- const files = ops.map((op) => op.file).filter(Boolean);
35415
- const projectRoot2 = await resolveProjectRoot(ctx, context);
35416
- {
35417
- const asked = new Set;
35418
- for (const file3 of files) {
35419
- const absPath = resolvePathFromProjectRoot(projectRoot2, file3);
35420
- if (asked.has(absPath))
35421
- continue;
35422
- asked.add(absPath);
35423
- const denial = await assertExternalDirectoryPermission(context, absPath);
35424
- if (denial)
35425
- return permissionDeniedResponse(denial);
35426
- }
35427
- }
35428
- await runAsk(context.ask({
35429
- permission: "edit",
35430
- patterns: files.map((f) => path4.relative(projectRoot2, resolvePathFromProjectRoot(projectRoot2, f))),
35431
- always: ["*"],
35432
- metadata: {}
35433
- }));
35434
- const resolvedOps = ops.map((op) => ({
35435
- ...op,
35436
- file: resolvePathFromProjectRoot(projectRoot2, op.file)
35437
- }));
35438
- const response = await callBridge(ctx, context, "transaction", { operations: resolvedOps });
35439
- if (response.success === false) {
35440
- throw new Error(response.message ?? "transaction failed");
35441
- }
35442
- return formatEditSummary(response);
35443
- }
35444
35960
  const file2 = args.filePath;
35445
35961
  if (!file2)
35446
35962
  throw new Error("'filePath' parameter is required");
@@ -35497,34 +36013,33 @@ function createEditTool(ctx, writeToolName = "write") {
35497
36013
  if (args.occurrence !== undefined)
35498
36014
  params.occurrence = args.occurrence;
35499
36015
  } else {
35500
- 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', 'edits' array, or 'operations' array.";
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.";
35501
36017
  throw new Error(`edit: no edit mode resolved from arguments.${hint}`);
35502
36018
  }
35503
- params.diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36019
+ params.diagnostics = diagnosticsOnEditDefault(ctx);
35504
36020
  params.include_diff_content = true;
35505
36021
  const data = await callBridge(ctx, context, command, params);
36022
+ if (data.success === false) {
36023
+ throw new Error(data.message || "edit failed");
36024
+ }
36025
+ let uiMeta;
36026
+ let uiTitle;
35506
36027
  if (data.success && data.diff) {
35507
36028
  const diff = data.diff;
35508
- const callID = getCallID2(context);
35509
- if (callID) {
35510
- const dp = relativeToWorktree(filePath, projectRoot);
35511
- const beforeContent = diff.before ?? "";
35512
- const afterContent = diff.after ?? "";
35513
- storeToolMetadata(context.sessionID, callID, {
35514
- title: dp,
35515
- metadata: {
35516
- diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
35517
- filediff: {
35518
- file: filePath,
35519
- before: beforeContent,
35520
- after: afterContent,
35521
- additions: diff.additions ?? 0,
35522
- deletions: diff.deletions ?? 0
35523
- },
35524
- diagnostics: {}
35525
- }
35526
- });
35527
- }
36029
+ uiTitle = relativeToWorktree(filePath, projectRoot);
36030
+ const beforeContent = diff.before ?? "";
36031
+ const afterContent = diff.after ?? "";
36032
+ uiMeta = {
36033
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
36034
+ filediff: {
36035
+ file: filePath,
36036
+ before: beforeContent,
36037
+ after: afterContent,
36038
+ additions: diff.additions ?? 0,
36039
+ deletions: diff.deletions ?? 0
36040
+ },
36041
+ diagnostics: {}
36042
+ };
35528
36043
  }
35529
36044
  let result = formatEditSummary(data);
35530
36045
  const globSkipNote = formatGlobSkipReasonsNote(data.format_skip_reasons);
@@ -35561,6 +36076,9 @@ Note: LSP server(s) did not respond in time: ${pendingServers.join(", ")}. Diagn
35561
36076
 
35562
36077
  Note: LSP server(s) exited during this edit: ${exitedServers.join(", ")}. Their diagnostics could not be collected.`;
35563
36078
  }
36079
+ if (uiMeta) {
36080
+ return { output: result, title: uiTitle ?? "", metadata: uiMeta };
36081
+ }
35564
36082
  return result;
35565
36083
  }
35566
36084
  };
@@ -35620,12 +36138,11 @@ function createApplyPatchTool(ctx) {
35620
36138
  return {
35621
36139
  description: APPLY_PATCH_DESCRIPTION,
35622
36140
  args: {
35623
- patchText: z8.string().describe("The full patch text including Begin/End markers"),
35624
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
36141
+ patchText: z8.string().describe("The full patch text including Begin/End markers")
35625
36142
  },
35626
36143
  execute: async (args, context) => {
35627
36144
  const patchText = args.patchText;
35628
- const diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36145
+ const diagnostics = diagnosticsOnEditDefault(ctx);
35629
36146
  if (!patchText)
35630
36147
  throw new Error("'patchText' is required");
35631
36148
  let hunks;
@@ -35710,6 +36227,12 @@ function createApplyPatchTool(ctx) {
35710
36227
  include_diff_content: true,
35711
36228
  multi_file_write_paths: multiFileWritePaths
35712
36229
  });
36230
+ if (writeResult.success === false) {
36231
+ throw new Error(writeResult.message ?? "write failed");
36232
+ }
36233
+ if (writeResult.rolled_back === true) {
36234
+ throw new Error("produced invalid syntax (rolled back)");
36235
+ }
35713
36236
  const wrDiff = writeResult.diff;
35714
36237
  perFileDiffs.push({
35715
36238
  filePath,
@@ -35735,7 +36258,12 @@ function createApplyPatchTool(ctx) {
35735
36258
  case "delete": {
35736
36259
  try {
35737
36260
  const before = await fs6.promises.readFile(filePath, "utf-8").catch(() => "");
35738
- await callBridge(ctx, context, "delete_file", { file: filePath });
36261
+ const deleteResult = await callBridge(ctx, context, "delete_file", {
36262
+ file: filePath
36263
+ });
36264
+ if (deleteResult.success === false) {
36265
+ throw new Error(deleteResult.message ?? "delete failed");
36266
+ }
35739
36267
  perFileDiffs.push({
35740
36268
  filePath,
35741
36269
  before,
@@ -35763,6 +36291,12 @@ function createApplyPatchTool(ctx) {
35763
36291
  include_diff_content: true,
35764
36292
  multi_file_write_paths: multiFileWritePaths
35765
36293
  });
36294
+ if (writeResult.success === false) {
36295
+ throw new Error(writeResult.message ?? "write failed");
36296
+ }
36297
+ if (writeResult.rolled_back === true) {
36298
+ throw new Error("produced invalid syntax (rolled back)");
36299
+ }
35766
36300
  const diags = writeResult.lsp_diagnostics;
35767
36301
  if (diags && diags.length > 0) {
35768
36302
  const errors3 = diags.filter((d) => d.severity === "error");
@@ -35842,10 +36376,11 @@ ${diagLines}`);
35842
36376
  `));
35843
36377
  }
35844
36378
  }
35845
- const callID = getCallID2(context);
35846
- if (callID) {
36379
+ {
35847
36380
  const diffByPath = new Map(perFileDiffs.map((d) => [d.filePath, d]));
35848
- const files = hunks.map((h) => {
36381
+ const failedPaths = new Set(failures);
36382
+ const appliedHunks = hunks.filter((h) => !failedPaths.has(h.path));
36383
+ const files = appliedHunks.map((h) => {
35849
36384
  const filePath = resolvePathFromProjectRoot(projectRoot, h.path);
35850
36385
  const rawMovePath = h.type === "update" ? h.move_path : undefined;
35851
36386
  const movePath = rawMovePath ? resolvePathFromProjectRoot(projectRoot, rawMovePath) : undefined;
@@ -35871,20 +36406,21 @@ ${diagLines}`);
35871
36406
  return `${prefix} ${f.relativePath}`;
35872
36407
  }).join(`
35873
36408
  `);
35874
- const title = `Success. Updated the following files:
36409
+ const title = failures.length > 0 ? `Partially applied (${files.length} of ${hunks.length}). Updated:
36410
+ ${fileList}` : `Success. Updated the following files:
35875
36411
  ${fileList}`;
35876
36412
  const diffText = files.map((f) => f.patch).filter(Boolean).join(`
35877
36413
  `);
35878
- storeToolMetadata(context.sessionID, callID, {
36414
+ return {
36415
+ output: results.join(`
36416
+ `),
35879
36417
  title,
35880
36418
  metadata: {
35881
36419
  diff: diffText,
35882
36420
  files
35883
36421
  }
35884
- });
36422
+ };
35885
36423
  }
35886
- return results.join(`
35887
- `);
35888
36424
  }
35889
36425
  };
35890
36426
  }
@@ -36408,10 +36944,6 @@ function navigationTools(ctx) {
36408
36944
  // src/tools/reading.ts
36409
36945
  import { tool as tool12 } from "@opencode-ai/plugin";
36410
36946
  var z12 = tool12.schema;
36411
- function getCallID3(ctx) {
36412
- const c = ctx;
36413
- return c.callID ?? c.callId ?? c.call_id;
36414
- }
36415
36947
  function buildZoomTitle(args) {
36416
36948
  if (!isEmptyParam(args.targets)) {
36417
36949
  if (Array.isArray(args.targets)) {
@@ -36574,25 +37106,26 @@ function readingTools(ctx) {
36574
37106
  const hasTargets = hasTargetsProvided(args.targets);
36575
37107
  const hasSymbols = !isEmptyParam(args.symbols);
36576
37108
  const wantCallgraph = args.callgraph === true;
36577
- const zoomCallID = getCallID3(context);
36578
- if (zoomCallID) {
36579
- const title = buildZoomTitle(args);
36580
- const display = { title };
36581
- if (hasFilePath)
36582
- display.filePath = args.filePath;
36583
- if (hasUrl)
36584
- display.url = args.url;
36585
- if (hasSymbols) {
36586
- display.symbols = typeof args.symbols === "string" ? args.symbols : JSON.stringify(args.symbols);
36587
- }
36588
- if (hasTargets)
36589
- display.targets = JSON.stringify(args.targets);
36590
- if (args.contextLines !== undefined)
36591
- display.contextLines = args.contextLines;
36592
- if (wantCallgraph)
36593
- display.callgraph = true;
36594
- storeToolMetadata(context.sessionID, zoomCallID, { title, metadata: display });
36595
- }
37109
+ const zoomTitle = buildZoomTitle(args);
37110
+ const zoomDisplay = { title: zoomTitle };
37111
+ if (hasFilePath)
37112
+ zoomDisplay.filePath = args.filePath;
37113
+ if (hasUrl)
37114
+ zoomDisplay.url = args.url;
37115
+ if (hasSymbols) {
37116
+ zoomDisplay.symbols = typeof args.symbols === "string" ? args.symbols : JSON.stringify(args.symbols);
37117
+ }
37118
+ if (hasTargets)
37119
+ zoomDisplay.targets = JSON.stringify(args.targets);
37120
+ if (args.contextLines !== undefined)
37121
+ zoomDisplay.contextLines = args.contextLines;
37122
+ if (wantCallgraph)
37123
+ zoomDisplay.callgraph = true;
37124
+ const withMeta = (output) => ({
37125
+ output,
37126
+ title: zoomTitle,
37127
+ metadata: zoomDisplay
37128
+ });
36596
37129
  if (hasTargets) {
36597
37130
  if (hasFilePath || hasUrl || hasSymbols) {
36598
37131
  throw new Error("'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'");
@@ -36632,7 +37165,7 @@ function readingTools(ctx) {
36632
37165
  name: t.symbol,
36633
37166
  response: responses[i] ?? { success: false, message: "missing zoom response" }
36634
37167
  }));
36635
- return formatZoomMultiTargetResult(entries).text;
37168
+ return withMeta(formatZoomMultiTargetResult(entries).text);
36636
37169
  }
36637
37170
  if (!hasFilePath && !hasUrl) {
36638
37171
  throw new Error("Provide exactly one of 'filePath', 'url', or 'targets'");
@@ -36665,9 +37198,9 @@ function readingTools(ctx) {
36665
37198
  if (response.success === false) {
36666
37199
  throw new Error(response.message || "zoom failed");
36667
37200
  }
36668
- return formatZoomText(targetLabel, response);
37201
+ return withMeta(formatZoomText(targetLabel, response));
36669
37202
  }
36670
- return formatZoomBatchResult(targetLabel, symbolsArray, results).text;
37203
+ return withMeta(formatZoomBatchResult(targetLabel, symbolsArray, results).text);
36671
37204
  }
36672
37205
  const params = { file: file2 };
36673
37206
  if (args.contextLines !== undefined)
@@ -36678,7 +37211,7 @@ function readingTools(ctx) {
36678
37211
  if (data.success === false) {
36679
37212
  throw new Error(data.message || "zoom failed");
36680
37213
  }
36681
- return formatZoomText(targetLabel, data);
37214
+ return withMeta(formatZoomText(targetLabel, data));
36682
37215
  }
36683
37216
  }
36684
37217
  };
@@ -36834,17 +37367,12 @@ var z13 = tool13.schema;
36834
37367
  function refactoringTools(ctx) {
36835
37368
  return {
36836
37369
  aft_refactor: {
36837
- description: `Workspace-wide refactoring operations that update imports and references across files.
37370
+ description: `Workspace-wide refactoring that updates imports and references across files.
36838
37371
 
36839
37372
  ` + `Ops:
36840
- ` + `- '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).
36841
- ` + ` Note: This moves code symbols between files. To rename/move an entire file, use aft_move instead.
36842
- ` + `- '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.
36843
- ` + `- 'inline': Replace a function call with the function's body, substituting args for params. Requires 'symbol', 'callSiteLine' (1-based). Validates single-return constraint.
36844
-
36845
- ` + `Each op requires specific parameters — see parameter descriptions for requirements.
36846
-
36847
- ` + "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.",
36848
37376
  args: {
36849
37377
  op: z13.enum(["move", "extract", "inline"]).describe("Refactoring operation"),
36850
37378
  filePath: z13.string().describe("Path to the source file (absolute or relative to project root)"),
@@ -37013,9 +37541,10 @@ function safetyTools(ctx) {
37013
37541
  if (Array.isArray(checkpointFiles)) {
37014
37542
  const projectRoot = await resolveProjectRoot(ctx, context);
37015
37543
  const uniqueParents = new Set;
37016
- for (const file2 of checkpointFiles) {
37017
- if (typeof file2 !== "string")
37544
+ for (const rawFile of checkpointFiles) {
37545
+ if (typeof rawFile !== "string")
37018
37546
  continue;
37547
+ const file2 = expandTilde(rawFile);
37019
37548
  const abs = path5.isAbsolute(file2) ? file2 : path5.resolve(projectRoot, file2);
37020
37549
  const parent = path5.dirname(abs);
37021
37550
  if (uniqueParents.has(parent))
@@ -37055,16 +37584,17 @@ function safetyTools(ctx) {
37055
37584
  const params = {};
37056
37585
  if (args.name !== undefined)
37057
37586
  params.name = args.name;
37058
- const payloadFiles = coerceStringArray(args.files);
37587
+ const payloadFiles = coerceStringArray(args.files).map(expandTilde);
37588
+ const filePathArg = typeof args.filePath === "string" ? expandTilde(args.filePath) : undefined;
37059
37589
  if (op === "checkpoint") {
37060
37590
  if (payloadFiles.length > 0) {
37061
37591
  params.files = payloadFiles;
37062
- } else if (args.filePath !== undefined) {
37063
- params.files = [args.filePath];
37592
+ } else if (filePathArg !== undefined) {
37593
+ params.files = [filePathArg];
37064
37594
  }
37065
37595
  } else {
37066
- if (args.filePath !== undefined)
37067
- params.file = args.filePath;
37596
+ if (filePathArg !== undefined)
37597
+ params.file = filePathArg;
37068
37598
  if (payloadFiles.length > 0)
37069
37599
  params.files = payloadFiles;
37070
37600
  }
@@ -37092,7 +37622,7 @@ function formatGrepOutput(response) {
37092
37622
  const totalMatches = response.total_matches ?? matches.length;
37093
37623
  const filesWithMatches = response.files_with_matches ?? new Set(matches.map((m) => m.file)).size;
37094
37624
  if (matches.length === 0) {
37095
- return `Found ${totalMatches} match(es) in ${filesWithMatches} file(s).`;
37625
+ return `Found ${totalMatches} match across ${filesWithMatches} file`;
37096
37626
  }
37097
37627
  const body = matches.map((match) => {
37098
37628
  const file2 = match.file ?? "unknown";
@@ -37103,7 +37633,7 @@ function formatGrepOutput(response) {
37103
37633
  `);
37104
37634
  return `${body}
37105
37635
 
37106
- Found ${totalMatches} match(es) in ${filesWithMatches} file(s).`;
37636
+ Found ${totalMatches} match across ${filesWithMatches} file`;
37107
37637
  }
37108
37638
  function normalizeGlob(pattern) {
37109
37639
  if (!pattern.includes("/") && !pattern.startsWith("**/")) {
@@ -37308,21 +37838,9 @@ function arg3(schema) {
37308
37838
  function semanticTools(ctx) {
37309
37839
  const searchTool = {
37310
37840
  description: [
37311
- "Find code with unified semantic, lexical, literal, and regex search. Returns ranked symbol/file results or exact matching lines, with routing metadata.",
37312
- "",
37313
- "When to reach for it:",
37314
- "- Exploring an unfamiliar area: 'where is rate limiting handled', 'how does auth flow work'",
37315
- "- Concept doesn't appear as a literal string: 'retry logic', 'cache invalidation', 'graceful shutdown'",
37316
- "- Filename-shaped concepts: 'the bridge spawn helper', 'the session detection module'",
37317
- "- Regex-shaped or exact text queries when you want AFT to classify and route automatically",
37318
- "- You know roughly what the function does but not what it's named",
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').",
37319
37842
  "",
37320
- "When NOT to use:",
37321
- "- You need exhaustive literal enumeration → use grep directly",
37322
- "- You want the file/module structure → use aft_outline",
37323
- "- You're following a call chain → use aft_callgraph",
37324
- "",
37325
- "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."
37326
37844
  ].join(`
37327
37845
  `),
37328
37846
  args: {
@@ -37366,118 +37884,8 @@ ${note}` : response.text;
37366
37884
  };
37367
37885
  }
37368
37886
 
37369
- // src/tools/structure.ts
37370
- import { tool as tool16 } from "@opencode-ai/plugin";
37371
- var z16 = tool16.schema;
37372
- function structureTools(ctx) {
37373
- return {
37374
- aft_transform: {
37375
- description: `Scope-aware structural code transformations with correct indentation.
37376
-
37377
- ` + `Language-specific: add_derive (Rust), add_struct_tags (Go), add_decorator (Python), wrap_try_catch (TS/JS), add_member (any language).
37378
-
37379
- ` + `Ops:
37380
- ` + `- 'add_member': Insert method/field into class, struct, or impl block. Requires 'container' (container name) and 'code'. Optional 'position'.
37381
- ` + `- 'add_derive': Add Rust derive macros to a struct/enum. Requires 'target' and 'derives' array. Deduplicates existing derives.
37382
- ` + `- 'wrap_try_catch': Wrap a TS/JS function body in try/catch. Requires 'target' (function name). Optional 'catchBody'.
37383
- ` + `- 'add_decorator': Add Python decorator to function/class. Requires 'target' and 'decorator' (without @). Optional 'position'.
37384
- ` + `- 'add_struct_tags': Add/update Go struct field tags. Requires 'target' (struct name), 'field', 'tag', 'value'.
37385
-
37386
- ` + "Each op requires specific parameters — see parameter descriptions for requirements. Use aft_safety checkpoint/undo before risky transforms.",
37387
- args: {
37388
- op: z16.enum(["add_member", "add_derive", "wrap_try_catch", "add_decorator", "add_struct_tags"]).describe("Transformation operation"),
37389
- filePath: z16.string().describe("Path to the source file (absolute or relative to project root)"),
37390
- 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."),
37391
- code: z16.string().optional().describe("Member code to insert (add_member)"),
37392
- position: z16.string().optional().describe("For add_member: 'first', 'last' (default), 'before:name', 'after:name'. For add_decorator: 'first' (default) or 'last' only."),
37393
- target: z16.string().optional().describe("Target symbol name (add_derive: struct/enum, wrap_try_catch: function, add_decorator: function/class, add_struct_tags: struct)"),
37394
- derives: z16.array(z16.string()).optional().describe("Derive macro names (add_derive — e.g. ['Clone', 'Debug'])"),
37395
- catchBody: z16.string().optional().describe("Catch block body (wrap_try_catch — default: 'throw error;')"),
37396
- decorator: z16.string().optional().describe("Decorator text without @ (add_decorator — e.g. 'staticmethod')"),
37397
- field: z16.string().optional().describe("Struct field name (add_struct_tags)"),
37398
- tag: z16.string().optional().describe("Tag key (add_struct_tags — e.g. 'json')"),
37399
- value: z16.string().optional().describe("Tag value (add_struct_tags — e.g. 'user_name,omitempty')"),
37400
- 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)")
37401
- },
37402
- execute: async (args, context) => {
37403
- const op = args.op;
37404
- if (op === "add_member") {
37405
- if (isEmptyParam(args.container))
37406
- throw new Error("'container' is required for 'add_member' op");
37407
- if (isEmptyParam(args.code))
37408
- throw new Error("'code' is required for 'add_member' op");
37409
- }
37410
- if (op === "add_derive" || op === "wrap_try_catch" || op === "add_decorator" || op === "add_struct_tags") {
37411
- if (isEmptyParam(args.target))
37412
- throw new Error(`'target' is required for '${op}' op`);
37413
- }
37414
- if (op === "add_derive" && isEmptyParam(args.derives)) {
37415
- throw new Error("'derives' array is required for 'add_derive' op");
37416
- }
37417
- if (op === "add_decorator" && isEmptyParam(args.decorator)) {
37418
- throw new Error("'decorator' is required for 'add_decorator' op");
37419
- }
37420
- if (op === "add_struct_tags") {
37421
- if (isEmptyParam(args.field))
37422
- throw new Error("'field' is required for 'add_struct_tags' op");
37423
- if (isEmptyParam(args.tag))
37424
- throw new Error("'tag' is required for 'add_struct_tags' op");
37425
- if (isEmptyParam(args.value))
37426
- throw new Error("'value' is required for 'add_struct_tags' op");
37427
- }
37428
- const filePath = await resolvePathArg(ctx, context, args.filePath);
37429
- {
37430
- const denial = await assertExternalDirectoryPermission(context, filePath);
37431
- if (denial)
37432
- return permissionDeniedResponse(denial);
37433
- }
37434
- const permissionError = await askEditPermission(context, [resolveRelativePattern(context, filePath)], { filepath: filePath });
37435
- if (permissionError)
37436
- return permissionDeniedResponse(permissionError);
37437
- const params = { file: filePath };
37438
- if (args.validate !== undefined)
37439
- params.validate = args.validate;
37440
- switch (op) {
37441
- case "add_member":
37442
- params.scope = args.container;
37443
- params.code = args.code;
37444
- if (args.position !== undefined)
37445
- params.position = args.position;
37446
- break;
37447
- case "add_derive":
37448
- params.target = args.target;
37449
- params.derives = args.derives;
37450
- break;
37451
- case "wrap_try_catch":
37452
- params.target = args.target;
37453
- if (args.catchBody !== undefined)
37454
- params.catch_body = args.catchBody;
37455
- break;
37456
- case "add_decorator":
37457
- params.target = args.target;
37458
- params.decorator = args.decorator;
37459
- if (args.position !== undefined)
37460
- params.position = args.position;
37461
- break;
37462
- case "add_struct_tags":
37463
- params.target = args.target;
37464
- params.field = args.field;
37465
- params.tag = args.tag;
37466
- params.value = args.value;
37467
- break;
37468
- }
37469
- const response = await callBridge(ctx, context, op, params);
37470
- if (response.success === false) {
37471
- throw new Error(response.message || `${op} failed`);
37472
- }
37473
- return JSON.stringify(response);
37474
- }
37475
- }
37476
- };
37477
- }
37478
-
37479
37887
  // src/workflow-hints.ts
37480
- var HEADING = "## Prefer AFT tools for token efficiency";
37888
+ var HEADING = "## IMPORTANT NOTICE about your tools";
37481
37889
  function buildWorkflowHints(opts) {
37482
37890
  const sections = [];
37483
37891
  const grepName = opts.hoistBuiltins ? "grep" : "aft_grep";
@@ -37493,14 +37901,28 @@ function buildWorkflowHints(opts) {
37493
37901
  const hasBash = !opts.disabledTools.has(bashName);
37494
37902
  const hasBgBash = hasBash && opts.bashBackgroundEnabled && !opts.disabledTools.has(bashStatusName);
37495
37903
  if (hasBash && opts.bashCompressionEnabled) {
37496
- 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
+ `));
37497
37911
  }
37498
37912
  if (hasOutline && hasZoom) {
37499
37913
  sections.push(`**Web/URL access**: \`aft_outline({ target: url })\` first for structure, then \`aft_zoom({ url, symbols: "<heading>" })\` for the specific section.`);
37500
37914
  }
37501
37915
  if (hasOutline && hasZoom && (hasGrep || hasSearch)) {
37916
+ const searchName = hasSearch ? "aft_search" : grepName;
37502
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.`;
37503
- 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
+ `));
37504
37926
  }
37505
37927
  if (hasInspect) {
37506
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.");
@@ -37518,13 +37940,22 @@ function buildWorkflowHints(opts) {
37518
37940
  `));
37519
37941
  }
37520
37942
  if (hasBash && hasBgBash) {
37521
- 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
+ `));
37522
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: "..." })\`.`);
37523
37952
  }
37524
37953
  if (sections.length === 0) {
37525
37954
  return null;
37526
37955
  }
37527
- 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.`);
37528
37959
  return `${HEADING}
37529
37960
 
37530
37961
  ${sections.join(`
@@ -37586,12 +38017,12 @@ var PLUGIN_VERSION = (() => {
37586
38017
  return "0.0.0";
37587
38018
  }
37588
38019
  })();
37589
- var ANNOUNCEMENT_VERSION = "0.36.0";
38020
+ var ANNOUNCEMENT_VERSION = "0.37.0";
37590
38021
  var ANNOUNCEMENT_FEATURES = [
37591
- "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.",
37592
- "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.",
37593
- "Code search steering: guidance and hints now point to `aft_search` and parallel `aft_*` tools instead of `grep`/`find` in bash.",
37594
- "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."
37595
38026
  ];
37596
38027
  var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/DSa65w8wuf";
37597
38028
  var plugin = async (input) => initializePluginForDirectory(input);
@@ -37816,17 +38247,25 @@ ${lines}
37816
38247
  });
37817
38248
  rpcServer.handle("status", async (params) => {
37818
38249
  const sessionID = params.sessionID || "rpc";
37819
- const cachedDir = getSessionDirectoryCached(sessionID);
37820
- const candidateDirs = new Set;
37821
- if (typeof cachedDir === "string" && cachedDir.length > 0) {
37822
- candidateDirs.add(cachedDir);
37823
- }
37824
- candidateDirs.add(input.directory);
37825
38250
  let bridge = null;
37826
- for (const dir of candidateDirs) {
37827
- bridge = pool.getActiveBridgeForRoot(dir);
37828
- if (bridge)
37829
- 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;
37830
38269
  }
37831
38270
  if (!bridge) {
37832
38271
  return {
@@ -37839,13 +38278,13 @@ ${lines}
37839
38278
  const cachedSessionId = cached2?.session;
37840
38279
  const cachedId = cachedSessionId?.id;
37841
38280
  if (cached2 !== null && cachedId === sessionID) {
37842
- return { success: true, ...cached2 };
38281
+ return { success: true, ...cached2, served_directory: servedDirectory };
37843
38282
  }
37844
38283
  const response = await bridge.send("status", { session_id: sessionID });
37845
38284
  if (response.success !== false) {
37846
38285
  bridge.cacheStatusSnapshot(response);
37847
38286
  }
37848
- return response;
38287
+ return { ...response, served_directory: servedDirectory };
37849
38288
  });
37850
38289
  const storageDir = configOverrides.storage_dir;
37851
38290
  rpcServer.handle("get-announcement", async () => {
@@ -37905,19 +38344,12 @@ Install: ${getManualInstallHint()}`).catch(() => {});
37905
38344
  cleanupWarnings(notifyOpts).catch(() => {});
37906
38345
  }
37907
38346
  const surface = aftConfig.tool_surface ?? "recommended";
37908
- const ALL_ONLY_TOOLS = new Set([
37909
- "aft_callgraph",
37910
- "aft_delete",
37911
- "aft_move",
37912
- "aft_transform",
37913
- "aft_refactor"
37914
- ]);
38347
+ const ALL_ONLY_TOOLS = new Set(["aft_callgraph", "aft_delete", "aft_move", "aft_refactor"]);
37915
38348
  const allTools = normalizeToolMap({
37916
38349
  ...surface !== "minimal" && (aftConfig.hoist_builtin_tools !== false ? hoistedTools(ctx) : aftPrefixedTools(ctx)),
37917
38350
  ...readingTools(ctx),
37918
38351
  ...safetyTools(ctx),
37919
38352
  ...surface !== "minimal" && importTools(ctx),
37920
- ...structureTools(ctx),
37921
38353
  ...navigationTools(ctx),
37922
38354
  ...surface !== "minimal" && astTools(ctx),
37923
38355
  ...surface !== "minimal" && aftConfig.semantic_search === true && semanticTools(ctx),
@@ -37944,6 +38376,7 @@ Install: ${getManualInstallHint()}`).catch(() => {});
37944
38376
  }
37945
38377
  log2(`Disabled ${disabled.size} tool(s): ${[...disabled].join(", ")}`);
37946
38378
  }
38379
+ instrumentToolMap(allTools);
37947
38380
  const autoUpdateEventHook = createAutoUpdateCheckerHook(input, {
37948
38381
  enabled: true,
37949
38382
  autoUpdate: aftConfig.auto_update ?? true,
@@ -37963,7 +38396,16 @@ Install: ${getManualInstallHint()}`).catch(() => {});
37963
38396
  "bash_status"
37964
38397
  ];
37965
38398
  const registeredTools = new Set(Object.keys(allTools));
37966
- 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
+ }
37967
38409
  const hintsAbsentTools = new Set;
37968
38410
  for (const name of HINTS_TOOL_NAMES) {
37969
38411
  if (!registeredTools.has(name))
@@ -38050,13 +38492,6 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38050
38492
  "tool.execute.after": async (toolInput, output) => {
38051
38493
  if (!output)
38052
38494
  return;
38053
- const stored = consumeToolMetadata(toolInput.sessionID, toolInput.callID);
38054
- if (stored) {
38055
- if (stored.title)
38056
- output.title = stored.title;
38057
- if (stored.metadata)
38058
- output.metadata = { ...output.metadata, ...stored.metadata };
38059
- }
38060
38495
  if (toolInput.tool === "bash" && output.output) {
38061
38496
  output.output = maybeAppendConflictsHint(output.output);
38062
38497
  }
@@ -38064,7 +38499,7 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38064
38499
  await appendInTurnBgCompletions({ ctx, directory: sessionDir, sessionID: toolInput.sessionID }, output);
38065
38500
  if (output.output !== undefined) {
38066
38501
  const activeBridge = ctx.pool.getActiveBridgeForRoot(sessionDir);
38067
- const suffix = statusBarSuffixForSession(toolInput.sessionID, activeBridge?.getStatusBar());
38502
+ const suffix = statusBarSuffixForSession(toolInput.sessionID, activeBridge?.getStatusBar(), toolInput.tool === "aft_inspect");
38068
38503
  if (suffix)
38069
38504
  output.output += suffix;
38070
38505
  }