@cortexkit/aft-opencode 0.36.1 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11597,6 +11597,9 @@ function error(message, meta) {
11597
11597
  var CONFLICT_HINT = `
11598
11598
 
11599
11599
  [Hint] Use aft_conflicts to see all conflict regions across files in a single call.`;
11600
+ var GREP_SEARCH_AFT_SEARCH_HINT = "DO NOT search code by running grep/rg in bash — it is unindexed, unranked, and serial. Use the `aft_search` tool instead (it auto-routes concepts, identifiers, regex, and literals).";
11601
+ var GREP_SEARCH_GREP_HINT = "DO NOT search code by running grep/rg in bash — it is unindexed, unranked, and serial. Use the `grep` tool instead (indexed and ranked).";
11602
+ var GREP_SEARCH_HINT_PREFIX = "DO NOT search code by running grep/rg in bash —";
11600
11603
  function maybeAppendConflictsHint(output) {
11601
11604
  if (!output.includes("Automatic merge failed; fix conflicts"))
11602
11605
  return output;
@@ -11604,6 +11607,146 @@ function maybeAppendConflictsHint(output) {
11604
11607
  return output;
11605
11608
  return output + CONFLICT_HINT;
11606
11609
  }
11610
+ function commandLeadsWithCodeSearch(command) {
11611
+ const trimmed = command.trim();
11612
+ if (!trimmed)
11613
+ return false;
11614
+ const afterCd = peelLeadingCdAnd(trimmed);
11615
+ if (afterCd === null)
11616
+ return false;
11617
+ const firstStage = firstPipelineStage(afterCd);
11618
+ if (firstStage === null)
11619
+ return false;
11620
+ const firstToken = readShellToken(firstStage, skipSpaces(firstStage, 0));
11621
+ if (firstToken === null)
11622
+ return false;
11623
+ return firstToken.token === "grep" || firstToken.token === "rg";
11624
+ }
11625
+ function maybeAppendGrepSearchHint(output, command, aftSearchRegistered) {
11626
+ if (output === "")
11627
+ return output;
11628
+ if (!commandLeadsWithCodeSearch(command))
11629
+ return output;
11630
+ if (output.includes(GREP_SEARCH_HINT_PREFIX))
11631
+ return output;
11632
+ const hint = aftSearchRegistered ? GREP_SEARCH_AFT_SEARCH_HINT : GREP_SEARCH_GREP_HINT;
11633
+ return `${output}
11634
+
11635
+ ${hint}`;
11636
+ }
11637
+ function peelLeadingCdAnd(command) {
11638
+ const first = readShellToken(command, skipSpaces(command, 0));
11639
+ if (first === null)
11640
+ return null;
11641
+ if (first.token !== "cd")
11642
+ return command;
11643
+ const dir = readShellToken(command, skipSpaces(command, first.end));
11644
+ if (dir === null)
11645
+ return null;
11646
+ if (!dir.token)
11647
+ return command;
11648
+ const afterDir = skipSpaces(command, dir.end);
11649
+ if (!command.startsWith("&&", afterDir))
11650
+ return command;
11651
+ return command.slice(afterDir + 2).trim();
11652
+ }
11653
+ function firstPipelineStage(command) {
11654
+ let quote = "none";
11655
+ let firstPipeIndex;
11656
+ for (let index = 0;index < command.length; index++) {
11657
+ const ch = command[index];
11658
+ if (quote === "single") {
11659
+ if (ch === "'")
11660
+ quote = "none";
11661
+ continue;
11662
+ }
11663
+ if (quote === "double") {
11664
+ if (ch === '"') {
11665
+ quote = "none";
11666
+ } else if (ch === "\\") {
11667
+ index++;
11668
+ } else if (ch === "`") {
11669
+ return null;
11670
+ }
11671
+ continue;
11672
+ }
11673
+ if (ch === "'") {
11674
+ quote = "single";
11675
+ } else if (ch === '"') {
11676
+ quote = "double";
11677
+ } else if (ch === "\\") {
11678
+ index++;
11679
+ } else if (ch === "`") {
11680
+ return null;
11681
+ } else if (ch === "|") {
11682
+ if (command[index + 1] === "|") {
11683
+ index++;
11684
+ } else if (firstPipeIndex === undefined) {
11685
+ firstPipeIndex = index;
11686
+ }
11687
+ }
11688
+ }
11689
+ if (quote !== "none")
11690
+ return null;
11691
+ return command.slice(0, firstPipeIndex ?? command.length).trim();
11692
+ }
11693
+ function readShellToken(command, start) {
11694
+ let quote = "none";
11695
+ let token = "";
11696
+ let index = start;
11697
+ for (;index < command.length; index++) {
11698
+ const ch = command[index];
11699
+ if (quote === "single") {
11700
+ if (ch === "'") {
11701
+ quote = "none";
11702
+ } else {
11703
+ token += ch;
11704
+ }
11705
+ continue;
11706
+ }
11707
+ if (quote === "double") {
11708
+ if (ch === '"') {
11709
+ quote = "none";
11710
+ } else if (ch === "\\") {
11711
+ index++;
11712
+ token += command[index] ?? "\\";
11713
+ } else if (ch === "`") {
11714
+ return null;
11715
+ } else {
11716
+ token += ch;
11717
+ }
11718
+ continue;
11719
+ }
11720
+ if (/\s/.test(ch))
11721
+ break;
11722
+ if (isTokenBoundary(ch))
11723
+ break;
11724
+ if (ch === "'") {
11725
+ quote = "single";
11726
+ } else if (ch === '"') {
11727
+ quote = "double";
11728
+ } else if (ch === "\\") {
11729
+ index++;
11730
+ token += command[index] ?? "\\";
11731
+ } else if (ch === "`") {
11732
+ return null;
11733
+ } else {
11734
+ token += ch;
11735
+ }
11736
+ }
11737
+ if (quote !== "none")
11738
+ return null;
11739
+ return { token, end: index };
11740
+ }
11741
+ function isTokenBoundary(ch) {
11742
+ return ch === "|" || ch === ";" || ch === "&" || ch === "<" || ch === ">";
11743
+ }
11744
+ function skipSpaces(input, start) {
11745
+ let index = start;
11746
+ while (index < input.length && /\s/.test(input[index]))
11747
+ index++;
11748
+ return index;
11749
+ }
11607
11750
  // ../aft-bridge/dist/bash-timeout.js
11608
11751
  function resolveBashKillTimeout(modelTimeout, foregroundWaitMs) {
11609
11752
  if (modelTimeout !== undefined && modelTimeout >= foregroundWaitMs) {
@@ -11617,6 +11760,21 @@ import { homedir } from "node:os";
11617
11760
  import { join } from "node:path";
11618
11761
  import { StringDecoder } from "node:string_decoder";
11619
11762
 
11763
+ // ../aft-bridge/dist/command-timeouts.js
11764
+ var LONG_RUNNING_COMMAND_TIMEOUT_MS = {
11765
+ callers: 60000,
11766
+ trace_to: 60000,
11767
+ trace_to_symbol: 60000,
11768
+ trace_data: 60000,
11769
+ impact: 60000,
11770
+ grep: 60000,
11771
+ glob: 60000,
11772
+ semantic_search: 60000
11773
+ };
11774
+ function timeoutForCommand(command) {
11775
+ return LONG_RUNNING_COMMAND_TIMEOUT_MS[command];
11776
+ }
11777
+
11620
11778
  // ../aft-bridge/dist/status-bar.js
11621
11779
  var STATUS_BAR_HEARTBEAT_CALLS = 15;
11622
11780
  function createStatusBarEmitState() {
@@ -11669,6 +11827,27 @@ var DEFAULT_BRIDGE_TIMEOUT_MS = 30000;
11669
11827
  var BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
11670
11828
  var SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5000;
11671
11829
  var MAX_STDOUT_BUFFER = 64 * 1024 * 1024;
11830
+ var STDOUT_BUFFER_COMPACT_THRESHOLD = 64 * 1024;
11831
+ var TERMINAL_BASH_STATUSES = new Set([
11832
+ "completed",
11833
+ "failed",
11834
+ "killed",
11835
+ "timed_out",
11836
+ "cancelled",
11837
+ "timeout"
11838
+ ]);
11839
+ function isTerminalBashStatus(status) {
11840
+ return typeof status === "string" && TERMINAL_BASH_STATUSES.has(status);
11841
+ }
11842
+ function bashTaskIdFrom(response) {
11843
+ const snakeCase = response.task_id;
11844
+ if (typeof snakeCase === "string" && snakeCase.length > 0)
11845
+ return snakeCase;
11846
+ const camelCase = response.taskId;
11847
+ if (typeof camelCase === "string" && camelCase.length > 0)
11848
+ return camelCase;
11849
+ return;
11850
+ }
11672
11851
  function tagStderrLine(line) {
11673
11852
  return /^\[aft(-\w+)?\] /.test(line) ? line : `[aft] ${line}`;
11674
11853
  }
@@ -11723,11 +11902,12 @@ function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
11723
11902
  if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
11724
11903
  return configOverrides;
11725
11904
  }
11726
- const maxSemanticTimeoutMs = bridgeTimeoutMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS ? bridgeTimeoutMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS : Math.max(1, bridgeTimeoutMs - 1);
11905
+ const semanticTransportBudgetMs = Math.max(bridgeTimeoutMs, LONG_RUNNING_COMMAND_TIMEOUT_MS.semantic_search ?? 0);
11906
+ const maxSemanticTimeoutMs = semanticTransportBudgetMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS ? semanticTransportBudgetMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS : Math.max(1, semanticTransportBudgetMs - 1);
11727
11907
  if (timeoutMs <= maxSemanticTimeoutMs) {
11728
11908
  return configOverrides;
11729
11909
  }
11730
- warn(`semantic.timeout_ms=${timeoutMs} exceeds bridge timeout budget; clamping to ${maxSemanticTimeoutMs}ms (bridge timeout: ${bridgeTimeoutMs}ms)`);
11910
+ warn(`semantic.timeout_ms=${timeoutMs} exceeds the semantic transport budget; clamping to ${maxSemanticTimeoutMs}ms (budget: ${semanticTransportBudgetMs}ms)`);
11731
11911
  return {
11732
11912
  ...configOverrides,
11733
11913
  semantic: {
@@ -11753,8 +11933,10 @@ class BinaryBridge {
11753
11933
  cwd;
11754
11934
  process = null;
11755
11935
  pending = new Map;
11936
+ outstandingBackgroundTaskIds = new Set;
11756
11937
  nextId = 1;
11757
11938
  stdoutBuffer = "";
11939
+ stdoutReadOffset = 0;
11758
11940
  stderrBuffer = "";
11759
11941
  stderrTail = [];
11760
11942
  _restartCount = 0;
@@ -11864,6 +12046,9 @@ class BinaryBridge {
11864
12046
  hasPendingRequests() {
11865
12047
  return this.pending.size > 0;
11866
12048
  }
12049
+ hasOutstandingBackgroundTasks() {
12050
+ return this.outstandingBackgroundTaskIds.size > 0;
12051
+ }
11867
12052
  getCwd() {
11868
12053
  return this.cwd;
11869
12054
  }
@@ -11985,7 +12170,7 @@ class BinaryBridge {
11985
12170
  entry.reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
11986
12171
  this.handleTimeout(requestSessionId);
11987
12172
  }, effectiveTimeoutMs);
11988
- this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
12173
+ this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress, command });
11989
12174
  if (!this.process?.stdin?.writable) {
11990
12175
  this.pending.delete(id);
11991
12176
  clearTimeout(timer);
@@ -12246,6 +12431,7 @@ class BinaryBridge {
12246
12431
  });
12247
12432
  this.process = child;
12248
12433
  this.stdoutBuffer = "";
12434
+ this.stdoutReadOffset = 0;
12249
12435
  this.stderrBuffer = "";
12250
12436
  this.lastChildActivityAt = 0;
12251
12437
  this.consecutiveRequestTimeouts = 0;
@@ -12290,24 +12476,41 @@ class BinaryBridge {
12290
12476
  ${tail}`;
12291
12477
  }
12292
12478
  onStdoutData(data) {
12479
+ if (this.stdoutReadOffset > STDOUT_BUFFER_COMPACT_THRESHOLD) {
12480
+ this.compactStdoutBuffer();
12481
+ }
12293
12482
  this.stdoutBuffer += data;
12294
- if (this.stdoutBuffer.length > MAX_STDOUT_BUFFER) {
12483
+ if (this.stdoutBuffer.length - this.stdoutReadOffset > MAX_STDOUT_BUFFER) {
12295
12484
  this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
12296
12485
  return;
12297
12486
  }
12298
12487
  let newlineIdx;
12299
12488
  while ((newlineIdx = this.stdoutBuffer.indexOf(`
12300
- `)) !== -1) {
12301
- const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
12302
- this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
12303
- if (!line)
12304
- continue;
12305
- this.processStdoutLine(line);
12489
+ `, this.stdoutReadOffset)) !== -1) {
12490
+ const line = this.stdoutBuffer.slice(this.stdoutReadOffset, newlineIdx).trim();
12491
+ this.stdoutReadOffset = newlineIdx + 1;
12492
+ if (line) {
12493
+ this.processStdoutLine(line);
12494
+ }
12495
+ if (this.stdoutReadOffset > STDOUT_BUFFER_COMPACT_THRESHOLD && this.stdoutReadOffset > this.stdoutBuffer.length / 2) {
12496
+ this.compactStdoutBuffer();
12497
+ }
12306
12498
  }
12499
+ if (this.stdoutReadOffset === this.stdoutBuffer.length) {
12500
+ this.stdoutBuffer = "";
12501
+ this.stdoutReadOffset = 0;
12502
+ }
12503
+ }
12504
+ compactStdoutBuffer() {
12505
+ if (this.stdoutReadOffset === 0)
12506
+ return;
12507
+ this.stdoutBuffer = this.stdoutBuffer.slice(this.stdoutReadOffset);
12508
+ this.stdoutReadOffset = 0;
12307
12509
  }
12308
12510
  flushStdoutBuffer() {
12309
- const line = this.stdoutBuffer.trim();
12511
+ const line = this.stdoutBuffer.slice(this.stdoutReadOffset).trim();
12310
12512
  this.stdoutBuffer = "";
12513
+ this.stdoutReadOffset = 0;
12311
12514
  if (!line)
12312
12515
  return;
12313
12516
  this.processStdoutLine(line);
@@ -12340,6 +12543,9 @@ class BinaryBridge {
12340
12543
  return;
12341
12544
  }
12342
12545
  if (response.type === "bash_completed") {
12546
+ const taskId = bashTaskIdFrom(response);
12547
+ if (taskId)
12548
+ this.outstandingBackgroundTaskIds.delete(taskId);
12343
12549
  this.onBashCompletion?.(response, this);
12344
12550
  return;
12345
12551
  }
@@ -12370,6 +12576,7 @@ class BinaryBridge {
12370
12576
  clearTimeout(entry.timer);
12371
12577
  this.consecutiveRequestTimeouts = 0;
12372
12578
  this.scheduleRestartCountReset();
12579
+ this.accountForBashTaskResponse(entry.command, response);
12373
12580
  this.captureStatusBar(response);
12374
12581
  entry.resolve(response);
12375
12582
  } else if (typeof response.type === "string") {
@@ -12379,6 +12586,18 @@ class BinaryBridge {
12379
12586
  this.warnVia(`Failed to parse stdout line: ${line}`);
12380
12587
  }
12381
12588
  }
12589
+ accountForBashTaskResponse(command, response) {
12590
+ const taskId = bashTaskIdFrom(response);
12591
+ if (!taskId)
12592
+ return;
12593
+ if (isTerminalBashStatus(response.status)) {
12594
+ this.outstandingBackgroundTaskIds.delete(taskId);
12595
+ return;
12596
+ }
12597
+ if (command === "bash" && response.success !== false) {
12598
+ this.outstandingBackgroundTaskIds.add(taskId);
12599
+ }
12600
+ }
12382
12601
  captureStatusBar(response) {
12383
12602
  const parsed = parseStatusBarCounts(response.status_bar);
12384
12603
  if (parsed)
@@ -12390,6 +12609,7 @@ class BinaryBridge {
12390
12609
  handleTimeout(triggeringSessionId) {
12391
12610
  this.consecutiveRequestTimeouts = 0;
12392
12611
  this.rejectAllPending(new Error(`${this.errorPrefix} bridge killed during sibling timeout — request aborted`));
12612
+ this.outstandingBackgroundTaskIds.clear();
12393
12613
  if (this.process) {
12394
12614
  this.process.kill("SIGKILL");
12395
12615
  this.process = null;
@@ -12419,6 +12639,7 @@ class BinaryBridge {
12419
12639
  }
12420
12640
  this.clearRestartResetTimer();
12421
12641
  this.configured = false;
12642
+ this.outstandingBackgroundTaskIds.clear();
12422
12643
  const tail = this.formatStderrTail();
12423
12644
  if (tail) {
12424
12645
  this.errorVia(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
@@ -14143,14 +14364,27 @@ function isCompressorHandledRunner(stage) {
14143
14364
  if (tokens.some((token) => token === "&&" || token === "||" || token.includes(";"))) {
14144
14365
  return false;
14145
14366
  }
14146
- const first = runnerName(tokens[0]);
14147
- const second = tokens[1];
14148
- const third = tokens[2];
14149
- const rest = tokens.slice(1);
14367
+ let tokenOffset = 0;
14368
+ while (tokenOffset < tokens.length && isEnvAssignment(tokens[tokenOffset])) {
14369
+ tokenOffset++;
14370
+ }
14371
+ const first = runnerName(tokens[tokenOffset]);
14372
+ const runnerArgs = tokens.slice(tokenOffset + 1);
14373
+ const second = runnerArgs[0];
14374
+ const third = runnerArgs[1];
14375
+ const rest = runnerArgs;
14150
14376
  if (!first)
14151
14377
  return false;
14152
- if (first === "bun")
14153
- return second === "test" || second === "run" && startsWithTest(third);
14378
+ if (first === "bun") {
14379
+ let args = rest;
14380
+ if (args[0] === "--cwd")
14381
+ args = args.slice(2);
14382
+ else if (args[0]?.startsWith("--cwd="))
14383
+ args = args.slice(1);
14384
+ const sub = args[0];
14385
+ const subNext = args[1];
14386
+ return sub === "test" || sub === "run" && startsWithTest(subNext);
14387
+ }
14154
14388
  if (first === "npm" || first === "pnpm") {
14155
14389
  return second === "test" || second === "run" && startsWithTest(third);
14156
14390
  }
@@ -14173,14 +14407,20 @@ function isCompressorHandledRunner(stage) {
14173
14407
  return hasBuildTask(rest, ["test", "check", "build", "assemble", "clean"]);
14174
14408
  }
14175
14409
  if (first === "mvn" || first === "mvnw") {
14176
- return hasBuildTask(rest, ["test", "verify", "package", "install"]);
14410
+ return hasBuildTask(rest, ["test", "verify", "package", "install", "clean"]);
14177
14411
  }
14178
14412
  if (first === "dotnet")
14179
14413
  return ["test", "build"].includes(second ?? "");
14180
14414
  if (first === "rspec")
14181
14415
  return true;
14182
- if (first === "rake")
14183
- return second === "test" || second === "spec";
14416
+ if (first === "rake") {
14417
+ const positionals = rest.filter((a) => !a.startsWith("-"));
14418
+ if (positionals.length === 0)
14419
+ return false;
14420
+ if (positionals.some((a) => a.includes("/") || a.includes(".") || a.includes("=")))
14421
+ return false;
14422
+ return positionals.some((a) => a === "test" || a === "spec");
14423
+ }
14184
14424
  if (first === "phpunit" || first === "pest")
14185
14425
  return true;
14186
14426
  if (first === "xcodebuild")
@@ -14203,6 +14443,11 @@ function isCompressorHandledRunner(stage) {
14203
14443
  "nox"
14204
14444
  ].includes(first);
14205
14445
  }
14446
+ function isEnvAssignment(token) {
14447
+ if (!token)
14448
+ return false;
14449
+ return /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(token);
14450
+ }
14206
14451
  function runnerName(token) {
14207
14452
  if (!token)
14208
14453
  return "";
@@ -14458,7 +14703,7 @@ function tokenizeStage(stage) {
14458
14703
  }
14459
14704
  // ../aft-bridge/dist/pool.js
14460
14705
  import { realpathSync as realpathSync2 } from "node:fs";
14461
- var DEFAULT_IDLE_TIMEOUT_MS = Infinity;
14706
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
14462
14707
  var DEFAULT_MAX_POOL_SIZE = 8;
14463
14708
  var CLEANUP_INTERVAL_MS = 60 * 1000;
14464
14709
  class BridgePool {
@@ -14532,7 +14777,7 @@ class BridgePool {
14532
14777
  cleanup() {
14533
14778
  const now = Date.now();
14534
14779
  for (const [dir, entry] of this.bridges) {
14535
- if (entry.bridge.hasPendingRequests())
14780
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14536
14781
  continue;
14537
14782
  if (now - entry.lastUsed > this.idleTimeoutMs) {
14538
14783
  entry.bridge.shutdown().catch((err) => this.error("cleanup shutdown failed:", err));
@@ -14540,7 +14785,7 @@ class BridgePool {
14540
14785
  }
14541
14786
  }
14542
14787
  for (const bridge of this.staleBridges) {
14543
- if (bridge.hasPendingRequests())
14788
+ if (bridge.hasPendingRequests() || bridge.hasOutstandingBackgroundTasks())
14544
14789
  continue;
14545
14790
  bridge.shutdown().catch((err) => this.error("stale cleanup shutdown failed:", err));
14546
14791
  this.staleBridges.delete(bridge);
@@ -14550,7 +14795,7 @@ class BridgePool {
14550
14795
  let oldestDir = null;
14551
14796
  let oldestTime = Infinity;
14552
14797
  for (const [dir, entry] of this.bridges) {
14553
- if (entry.bridge.hasPendingRequests())
14798
+ if (entry.bridge.hasPendingRequests() || entry.bridge.hasOutstandingBackgroundTasks())
14554
14799
  continue;
14555
14800
  if (entry.lastUsed < oldestTime) {
14556
14801
  oldestTime = entry.lastUsed;
@@ -32881,7 +33126,14 @@ function writeTerminal(terminal, data) {
32881
33126
 
32882
33127
  // src/shared/rpc-server.ts
32883
33128
  import { randomBytes as randomBytes2 } from "node:crypto";
32884
- import { mkdirSync as mkdirSync10, renameSync as renameSync8, unlinkSync as unlinkSync7, writeFileSync as writeFileSync10 } from "node:fs";
33129
+ import {
33130
+ mkdirSync as mkdirSync10,
33131
+ readdirSync as readdirSync5,
33132
+ readFileSync as readFileSync13,
33133
+ renameSync as renameSync8,
33134
+ unlinkSync as unlinkSync7,
33135
+ writeFileSync as writeFileSync10
33136
+ } from "node:fs";
32885
33137
  import { createServer } from "node:http";
32886
33138
  import { dirname as dirname9, join as join19 } from "node:path";
32887
33139
 
@@ -32896,6 +33148,41 @@ function rpcPortFileDir(storageDir, directory) {
32896
33148
  const hash2 = projectHash(directory);
32897
33149
  return join18(storageDir, "rpc", hash2, "ports");
32898
33150
  }
33151
+ function isPidAlive(pid) {
33152
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
33153
+ return false;
33154
+ try {
33155
+ process.kill(pid, 0);
33156
+ return true;
33157
+ } catch (err) {
33158
+ return err.code === "EPERM";
33159
+ }
33160
+ }
33161
+ function parseRpcPortRecord(content) {
33162
+ const trimmed = content.trim();
33163
+ if (trimmed.length === 0)
33164
+ return null;
33165
+ if (trimmed.startsWith("{")) {
33166
+ try {
33167
+ const parsed = JSON.parse(trimmed);
33168
+ const port2 = typeof parsed.port === "number" ? parsed.port : Number.NaN;
33169
+ if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535)
33170
+ return null;
33171
+ return {
33172
+ port: port2,
33173
+ token: typeof parsed.token === "string" ? parsed.token : null,
33174
+ pid: typeof parsed.pid === "number" && Number.isInteger(parsed.pid) ? parsed.pid : undefined,
33175
+ started_at: typeof parsed.started_at === "number" ? parsed.started_at : undefined
33176
+ };
33177
+ } catch {
33178
+ return null;
33179
+ }
33180
+ }
33181
+ const port = Number.parseInt(trimmed, 10);
33182
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
33183
+ return null;
33184
+ return { port, token: null };
33185
+ }
32899
33186
 
32900
33187
  // src/shared/rpc-server.ts
32901
33188
  class AftRpcServer {
@@ -32942,6 +33229,7 @@ class AftRpcServer {
32942
33229
  }), { encoding: "utf-8", mode: 384 });
32943
33230
  renameSync8(tmpPath, this.portFilePath);
32944
33231
  log2(`RPC server listening on 127.0.0.1:${this.port}`);
33232
+ this.sweepDeadPortFiles();
32945
33233
  } catch (err) {
32946
33234
  warn2(`Failed to write RPC port file: ${err}`);
32947
33235
  }
@@ -32950,6 +33238,31 @@ class AftRpcServer {
32950
33238
  server.unref();
32951
33239
  });
32952
33240
  }
33241
+ sweepDeadPortFiles() {
33242
+ let entries;
33243
+ try {
33244
+ entries = readdirSync5(this.portsDir);
33245
+ } catch {
33246
+ return;
33247
+ }
33248
+ for (const entry of entries) {
33249
+ if (!entry.endsWith(".json"))
33250
+ continue;
33251
+ const filePath = join19(this.portsDir, entry);
33252
+ if (filePath === this.portFilePath)
33253
+ continue;
33254
+ try {
33255
+ const record2 = parseRpcPortRecord(readFileSync13(filePath, "utf-8"));
33256
+ if (record2 === null) {
33257
+ unlinkSync7(filePath);
33258
+ continue;
33259
+ }
33260
+ if (record2.pid !== undefined && !isPidAlive(record2.pid)) {
33261
+ unlinkSync7(filePath);
33262
+ }
33263
+ } catch {}
33264
+ }
33265
+ }
32953
33266
  stop() {
32954
33267
  if (this.server) {
32955
33268
  this.server.close();
@@ -33074,6 +33387,38 @@ function warmSessionDirectory(client, sessionId, fallbackDirectory) {
33074
33387
  return;
33075
33388
  getSessionDirectory(client, sessionId, fallbackDirectory);
33076
33389
  }
33390
+ var VERIFY_TTL_MS = 15000;
33391
+ var verifyCache = new Map;
33392
+ async function verifySessionDirectory(client, sessionId) {
33393
+ if (!sessionId)
33394
+ return null;
33395
+ const hit = verifyCache.get(sessionId);
33396
+ if (hit && Date.now() - hit.verifiedAt < VERIFY_TTL_MS)
33397
+ return hit.directory;
33398
+ const c = client;
33399
+ const sessionApi = c?.session;
33400
+ if (!sessionApi || typeof sessionApi.get !== "function")
33401
+ return null;
33402
+ let dir = null;
33403
+ try {
33404
+ const result = await sessionApi.get({ path: { id: sessionId } });
33405
+ const session = result?.data ?? result;
33406
+ if (session && typeof session.directory === "string" && session.directory.length > 0) {
33407
+ dir = session.directory;
33408
+ }
33409
+ } catch {
33410
+ return null;
33411
+ }
33412
+ verifyCache.set(sessionId, { directory: dir, verifiedAt: Date.now() });
33413
+ if (verifyCache.size > CACHE_MAX_ENTRIES) {
33414
+ const oldest = verifyCache.keys().next().value;
33415
+ if (oldest !== undefined)
33416
+ verifyCache.delete(oldest);
33417
+ }
33418
+ if (dir !== null)
33419
+ setCache(sessionId, dir);
33420
+ return dir;
33421
+ }
33077
33422
 
33078
33423
  // src/shared/status.ts
33079
33424
  function asRecord(value) {
@@ -33286,7 +33631,7 @@ function formatStatusMarkdown(status) {
33286
33631
 
33287
33632
  // src/shared/tui-config.ts
33288
33633
  var import_comment_json4 = __toESM(require_src2(), 1);
33289
- import { existsSync as existsSync14, mkdirSync as mkdirSync11, readFileSync as readFileSync13, writeFileSync as writeFileSync11 } from "node:fs";
33634
+ import { existsSync as existsSync14, mkdirSync as mkdirSync11, readFileSync as readFileSync14, writeFileSync as writeFileSync11 } from "node:fs";
33290
33635
  import { dirname as dirname10, join as join21 } from "node:path";
33291
33636
 
33292
33637
  // src/shared/opencode-config-dir.ts
@@ -33334,7 +33679,7 @@ function ensureTuiPluginEntry() {
33334
33679
  const configPath = resolveTuiConfigPath();
33335
33680
  let config2 = {};
33336
33681
  if (existsSync14(configPath)) {
33337
- config2 = import_comment_json4.parse(readFileSync13(configPath, "utf-8")) ?? {};
33682
+ config2 = import_comment_json4.parse(readFileSync14(configPath, "utf-8")) ?? {};
33338
33683
  }
33339
33684
  const plugins = Array.isArray(config2.plugin) ? config2.plugin.filter((value) => typeof value === "string") : [];
33340
33685
  if (plugins.some((plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) || plugin.includes("opencode-plugin") || plugin.includes("aft-opencode"))) {
@@ -33430,6 +33775,21 @@ function clearStatusBarSession(sessionID) {
33430
33775
  emitStateBySession.delete(sessionID);
33431
33776
  }
33432
33777
 
33778
+ // src/sync-watch-abort.ts
33779
+ var abortFlags = new Map;
33780
+ function signalSyncWatchAbort(sessionID) {
33781
+ const key = sessionID || "__default__";
33782
+ abortFlags.set(key, true);
33783
+ }
33784
+ function clearSyncWatchAbort(sessionID) {
33785
+ const key = sessionID || "__default__";
33786
+ abortFlags.delete(key);
33787
+ }
33788
+ function isSyncWatchAborted(sessionID) {
33789
+ const key = sessionID || "__default__";
33790
+ return abortFlags.get(key) === true;
33791
+ }
33792
+
33433
33793
  // src/tool-perf.ts
33434
33794
  import { AsyncLocalStorage } from "node:async_hooks";
33435
33795
  var perfStore = new AsyncLocalStorage;
@@ -33502,19 +33862,6 @@ function isEmptyParam(value) {
33502
33862
  return false;
33503
33863
  }
33504
33864
  var BASH_TRANSPORT_TIMEOUT_MS = 30000;
33505
- var LONG_RUNNING_COMMAND_TIMEOUT_MS = {
33506
- callers: 60000,
33507
- trace_to: 60000,
33508
- trace_to_symbol: 60000,
33509
- trace_data: 60000,
33510
- impact: 60000,
33511
- grep: 60000,
33512
- glob: 60000,
33513
- semantic_search: 60000
33514
- };
33515
- function timeoutForCommand(command) {
33516
- return LONG_RUNNING_COMMAND_TIMEOUT_MS[command];
33517
- }
33518
33865
  function asPlainObject(value) {
33519
33866
  if (!value || typeof value !== "object" || Array.isArray(value))
33520
33867
  return;
@@ -34091,374 +34438,47 @@ ${hint}`;
34091
34438
  };
34092
34439
  }
34093
34440
 
34094
- // src/tools/conflicts.ts
34441
+ // src/tools/bash.ts
34095
34442
  import { tool as tool4 } from "@opencode-ai/plugin";
34096
- var z4 = tool4.schema;
34097
- function conflictTools(ctx) {
34098
- return {
34099
- aft_conflicts: {
34100
- description: "Show all git merge conflicts across the repository — returns line-numbered conflict regions with context for every conflicted file in a single call. Conflicts are discovered from the git repository's top level. By default it inspects the session's project repository; pass `path` to inspect a different repository or git worktree (e.g. where a rebase/merge is running).",
34101
- args: {
34102
- path: z4.string().describe("Optional path inside the git repository or worktree to inspect (absolute or relative to project root). Conflicts are discovered from that repository's top level. Defaults to the session project root.").optional()
34103
- },
34104
- execute: async (args, context) => {
34105
- const params = {};
34106
- if (!isEmptyParam(args?.path))
34107
- params.path = args.path;
34108
- const response = await callBridge(ctx, context, "git_conflicts", params);
34109
- if (response.success === false) {
34110
- throw new Error(response.message || "git_conflicts failed");
34111
- }
34112
- return response.text;
34113
- }
34114
- }
34115
- };
34116
- }
34117
-
34118
- // src/tools/hoisted.ts
34119
- import * as fs6 from "node:fs";
34120
- import * as path4 from "node:path";
34121
- import { tool as tool8 } from "@opencode-ai/plugin";
34122
34443
 
34123
- // src/patch-parser.ts
34124
- function stripHeredoc(input) {
34125
- const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
34126
- return heredocMatch ? heredocMatch[2] : input;
34127
- }
34128
- function parsePatchHeader(lines, startIdx) {
34129
- const line = lines[startIdx];
34130
- if (line.startsWith("*** Add File:")) {
34131
- const filePath = line.slice("*** Add File:".length).trim();
34132
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34133
- }
34134
- if (line.startsWith("*** Delete File:")) {
34135
- const filePath = line.slice("*** Delete File:".length).trim();
34136
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34444
+ // src/shared/subagent-detect.ts
34445
+ var CACHE_MAX_ENTRIES2 = 200;
34446
+ var cache2 = new Map;
34447
+ async function resolveIsSubagent(client, sessionId, _fallbackDirectory) {
34448
+ if (!sessionId) {
34449
+ sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
34450
+ return false;
34137
34451
  }
34138
- if (line.startsWith("*** Update File:")) {
34139
- const filePath = line.slice("*** Update File:".length).trim();
34140
- let movePath;
34141
- let nextIdx = startIdx + 1;
34142
- if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
34143
- movePath = lines[nextIdx].slice("*** Move to:".length).trim();
34144
- nextIdx++;
34145
- }
34146
- return filePath ? { filePath, movePath, nextIdx } : null;
34452
+ const cached2 = cache2.get(sessionId);
34453
+ if (cached2) {
34454
+ cache2.delete(sessionId);
34455
+ cache2.set(sessionId, cached2);
34456
+ return cached2.isSubagent;
34147
34457
  }
34148
- return null;
34149
- }
34150
- function parseAddFileContent(lines, startIdx) {
34151
- let content = "";
34152
- let i = startIdx;
34153
- while (i < lines.length && !lines[i].startsWith("***")) {
34154
- if (lines[i].startsWith("+")) {
34155
- content += `${lines[i].substring(1)}
34156
- `;
34157
- }
34158
- i++;
34458
+ const c = client;
34459
+ const sessionApi = c?.session;
34460
+ if (!sessionApi || typeof sessionApi.get !== "function") {
34461
+ sessionLog(sessionId, `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`);
34462
+ setCache2(sessionId, false);
34463
+ return false;
34159
34464
  }
34160
- if (content.endsWith(`
34161
- `)) {
34162
- content = content.slice(0, -1);
34465
+ sessionLog(sessionId, `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`);
34466
+ let isSubagent = false;
34467
+ let parentIdRaw;
34468
+ try {
34469
+ const result = await sessionApi.get({
34470
+ path: { id: sessionId }
34471
+ });
34472
+ const session = result?.data ?? result;
34473
+ parentIdRaw = session?.parentID;
34474
+ isSubagent = session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
34475
+ sessionLog(sessionId, `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`);
34476
+ } catch (err) {
34477
+ sessionWarn(sessionId, `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`);
34478
+ return false;
34163
34479
  }
34164
- return { content, nextIdx: i };
34165
- }
34166
- function parseUpdateFileChunks(lines, startIdx) {
34167
- const chunks = [];
34168
- let i = startIdx;
34169
- while (i < lines.length && !lines[i].startsWith("***")) {
34170
- if (lines[i].startsWith("@@")) {
34171
- const contextLine = lines[i].substring(2).trim();
34172
- i++;
34173
- const oldLines = [];
34174
- const newLines = [];
34175
- let isEndOfFile = false;
34176
- while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
34177
- const changeLine = lines[i];
34178
- if (changeLine === "*** End of File") {
34179
- isEndOfFile = true;
34180
- i++;
34181
- break;
34182
- }
34183
- if (changeLine.startsWith(" ")) {
34184
- const content = changeLine.substring(1);
34185
- oldLines.push(content);
34186
- newLines.push(content);
34187
- } else if (changeLine.startsWith("-")) {
34188
- oldLines.push(changeLine.substring(1));
34189
- } else if (changeLine.startsWith("+")) {
34190
- newLines.push(changeLine.substring(1));
34191
- }
34192
- i++;
34193
- }
34194
- chunks.push({
34195
- old_lines: oldLines,
34196
- new_lines: newLines,
34197
- change_context: contextLine || undefined,
34198
- is_end_of_file: isEndOfFile || undefined
34199
- });
34200
- } else {
34201
- i++;
34202
- }
34203
- }
34204
- return { chunks, nextIdx: i };
34205
- }
34206
- var MAX_PATCH_SIZE = 1024 * 1024;
34207
- var MAX_HUNKS = 500;
34208
- function parsePatch(patchText) {
34209
- if (patchText.length > MAX_PATCH_SIZE) {
34210
- throw new Error(`Patch too large: ${patchText.length} bytes exceeds limit of ${MAX_PATCH_SIZE} bytes`);
34211
- }
34212
- const cleaned = stripHeredoc(patchText.trim());
34213
- const lines = cleaned.split(`
34214
- `);
34215
- const hunks = [];
34216
- const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
34217
- const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
34218
- if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
34219
- throw new Error("Invalid patch format: missing *** Begin Patch / *** End Patch markers");
34220
- }
34221
- let i = beginIdx + 1;
34222
- while (i < endIdx) {
34223
- const header = parsePatchHeader(lines, i);
34224
- if (!header) {
34225
- i++;
34226
- continue;
34227
- }
34228
- if (hunks.length >= MAX_HUNKS) {
34229
- throw new Error(`Patch exceeds maximum of ${MAX_HUNKS} file operations`);
34230
- }
34231
- if (lines[i].startsWith("*** Add File:")) {
34232
- const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
34233
- hunks.push({ type: "add", path: header.filePath, contents: content });
34234
- i = nextIdx;
34235
- } else if (lines[i].startsWith("*** Delete File:")) {
34236
- hunks.push({ type: "delete", path: header.filePath });
34237
- i = header.nextIdx;
34238
- } else if (lines[i].startsWith("*** Update File:")) {
34239
- const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
34240
- hunks.push({
34241
- type: "update",
34242
- path: header.filePath,
34243
- move_path: header.movePath,
34244
- chunks
34245
- });
34246
- i = nextIdx;
34247
- } else {
34248
- i++;
34249
- }
34250
- }
34251
- return hunks;
34252
- }
34253
- function normalizeUnicode(str) {
34254
- return str.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"').replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-").replace(/\u2026/g, "...").replace(/\u00A0/g, " ");
34255
- }
34256
- function normalizeIndent(str) {
34257
- return str.replace(/^[\t ]+/, (m) => " ".repeat(m.length));
34258
- }
34259
- function tryMatch(lines, pattern, startIndex, compare, eof) {
34260
- if (eof) {
34261
- const fromEnd = lines.length - pattern.length;
34262
- if (fromEnd >= startIndex) {
34263
- let matches = true;
34264
- for (let j = 0;j < pattern.length; j++) {
34265
- if (!compare(lines[fromEnd + j], pattern[j])) {
34266
- matches = false;
34267
- break;
34268
- }
34269
- }
34270
- if (matches)
34271
- return fromEnd;
34272
- }
34273
- }
34274
- for (let i = startIndex;i <= lines.length - pattern.length; i++) {
34275
- let matches = true;
34276
- for (let j = 0;j < pattern.length; j++) {
34277
- if (!compare(lines[i + j], pattern[j])) {
34278
- matches = false;
34279
- break;
34280
- }
34281
- }
34282
- if (matches)
34283
- return i;
34284
- }
34285
- return -1;
34286
- }
34287
- function seekSequenceTiered(lines, pattern, startIndex, eof = false) {
34288
- if (pattern.length === 0)
34289
- return null;
34290
- const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
34291
- if (exact !== -1)
34292
- return { found: exact, tier: "exact" };
34293
- const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
34294
- if (rstrip !== -1)
34295
- return { found: rstrip, tier: "rstrip" };
34296
- const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
34297
- if (trim !== -1)
34298
- return { found: trim, tier: "trim" };
34299
- const indent = tryMatch(lines, pattern, startIndex, (a, b) => normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd(), eof);
34300
- if (indent !== -1)
34301
- return { found: indent, tier: "indent" };
34302
- const unicode = tryMatch(lines, pattern, startIndex, (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), eof);
34303
- if (unicode !== -1)
34304
- return { found: unicode, tier: "unicode" };
34305
- return null;
34306
- }
34307
- function seekSequence(lines, pattern, startIndex, eof = false) {
34308
- const r = seekSequenceTiered(lines, pattern, startIndex, eof);
34309
- return r ? r.found : -1;
34310
- }
34311
- function findClosestPartialMatch(lines, pattern) {
34312
- if (pattern.length === 0 || lines.length === 0)
34313
- return null;
34314
- const compareAny = (a, b) => a === b || a.trimEnd() === b.trimEnd() || a.trim() === b.trim() || normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd() || normalizeUnicode(a.trim()) === normalizeUnicode(b.trim());
34315
- const candidates = [];
34316
- for (let i = 0;i < lines.length && candidates.length < 16; i++) {
34317
- if (compareAny(lines[i], pattern[0]))
34318
- candidates.push(i);
34319
- }
34320
- if (candidates.length === 0)
34321
- return null;
34322
- let best = { lineNumber: -1, matchedLines: 0, firstDivergence: -1 };
34323
- for (const start of candidates) {
34324
- let matched = 0;
34325
- for (let j = 0;j < pattern.length && start + j < lines.length; j++) {
34326
- if (!compareAny(lines[start + j], pattern[j]))
34327
- break;
34328
- matched++;
34329
- }
34330
- if (matched > best.matchedLines) {
34331
- best = {
34332
- lineNumber: start + 1,
34333
- matchedLines: matched,
34334
- firstDivergence: matched
34335
- };
34336
- }
34337
- }
34338
- return best.lineNumber === -1 ? null : best;
34339
- }
34340
- function applyUpdateChunks(originalContent, filePath, chunks) {
34341
- const originalLines = originalContent.split(`
34342
- `);
34343
- if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34344
- originalLines.pop();
34345
- }
34346
- const replacements = [];
34347
- let lineIndex = 0;
34348
- for (const chunk of chunks) {
34349
- if (chunk.change_context) {
34350
- const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
34351
- if (contextIdx === -1) {
34352
- throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
34353
- }
34354
- lineIndex = contextIdx + 1;
34355
- }
34356
- if (chunk.old_lines.length === 0) {
34357
- let insertionIdx;
34358
- if (chunk.change_context) {
34359
- insertionIdx = lineIndex;
34360
- } else if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
34361
- insertionIdx = originalLines.length - 1;
34362
- } else {
34363
- insertionIdx = originalLines.length;
34364
- }
34365
- replacements.push([insertionIdx, 0, chunk.new_lines]);
34366
- continue;
34367
- }
34368
- let pattern = chunk.old_lines;
34369
- let newSlice = chunk.new_lines;
34370
- let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34371
- if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
34372
- pattern = pattern.slice(0, -1);
34373
- if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
34374
- newSlice = newSlice.slice(0, -1);
34375
- }
34376
- found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
34377
- }
34378
- if (found !== -1) {
34379
- replacements.push([found, pattern.length, newSlice]);
34380
- lineIndex = found + pattern.length;
34381
- } else {
34382
- const newSliceTrimmed = newSlice.filter((line) => line.trim().length > 0);
34383
- const alreadyApplied = newSliceTrimmed.length > 0 && seekSequence(originalLines, newSliceTrimmed, 0, chunk.is_end_of_file) !== -1;
34384
- const closest = findClosestPartialMatch(originalLines, pattern);
34385
- let closestHint = "";
34386
- if (closest && closest.matchedLines > 0) {
34387
- const fileLineNo = closest.lineNumber + closest.firstDivergence;
34388
- const expectedLine = pattern[closest.firstDivergence];
34389
- const actualLine = fileLineNo - 1 < originalLines.length ? originalLines[fileLineNo - 1] : "<EOF>";
34390
- closestHint = `
34391
-
34392
- Closest match starts at line ${closest.lineNumber} ` + `(${closest.matchedLines} of ${pattern.length} lines matched).
34393
- ` + `First divergence at line ${fileLineNo}:
34394
- ` + ` expected: ${JSON.stringify(expectedLine)}
34395
- ` + ` actual: ${JSON.stringify(actualLine)}`;
34396
- }
34397
- const triedTiers = "exact, trimEnd, trim, indent (tab/space), unicode";
34398
- const alreadyAppliedHint = alreadyApplied ? `
34399
-
34400
- Hint: the replacement content for this hunk already appears in the file. ` + "The patch may have been partially applied in a prior turn — re-read the file " + "to confirm which hunks still need to apply." : "";
34401
- throw new Error(`Failed to find expected lines in ${filePath}:
34402
- ${chunk.old_lines.join(`
34403
- `)}
34404
-
34405
- ` + `Tried match tiers: ${triedTiers}.${closestHint}${alreadyAppliedHint}`);
34406
- }
34407
- }
34408
- replacements.sort((a, b) => a[0] - b[0]);
34409
- const result = [...originalLines];
34410
- for (let i = replacements.length - 1;i >= 0; i--) {
34411
- const [startIdx, oldLen, newSegment] = replacements[i];
34412
- result.splice(startIdx, oldLen, ...newSegment);
34413
- }
34414
- if (result.length === 0 || result[result.length - 1] !== "") {
34415
- result.push("");
34416
- }
34417
- return result.join(`
34418
- `);
34419
- }
34420
-
34421
- // src/tools/bash.ts
34422
- import { tool as tool5 } from "@opencode-ai/plugin";
34423
-
34424
- // src/shared/subagent-detect.ts
34425
- var CACHE_MAX_ENTRIES2 = 200;
34426
- var cache2 = new Map;
34427
- async function resolveIsSubagent(client, sessionId, _fallbackDirectory) {
34428
- if (!sessionId) {
34429
- sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
34430
- return false;
34431
- }
34432
- const cached2 = cache2.get(sessionId);
34433
- if (cached2) {
34434
- cache2.delete(sessionId);
34435
- cache2.set(sessionId, cached2);
34436
- return cached2.isSubagent;
34437
- }
34438
- const c = client;
34439
- const sessionApi = c?.session;
34440
- if (!sessionApi || typeof sessionApi.get !== "function") {
34441
- sessionLog(sessionId, `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`);
34442
- setCache2(sessionId, false);
34443
- return false;
34444
- }
34445
- sessionLog(sessionId, `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`);
34446
- let isSubagent = false;
34447
- let parentIdRaw;
34448
- try {
34449
- const result = await sessionApi.get({
34450
- path: { id: sessionId }
34451
- });
34452
- const session = result?.data ?? result;
34453
- parentIdRaw = session?.parentID;
34454
- isSubagent = session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
34455
- sessionLog(sessionId, `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`);
34456
- } catch (err) {
34457
- sessionWarn(sessionId, `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`);
34458
- return false;
34459
- }
34460
- setCache2(sessionId, isSubagent);
34461
- return isSubagent;
34480
+ setCache2(sessionId, isSubagent);
34481
+ return isSubagent;
34462
34482
  }
34463
34483
  function setCache2(sessionId, isSubagent) {
34464
34484
  if (cache2.has(sessionId))
@@ -34472,7 +34492,7 @@ function setCache2(sessionId, isSubagent) {
34472
34492
  }
34473
34493
 
34474
34494
  // src/tools/bash.ts
34475
- var z5 = tool5.schema;
34495
+ var z4 = tool4.schema;
34476
34496
  var METADATA_PREVIEW_LIMIT = 30 * 1024;
34477
34497
  var FOREGROUND_POLL_INTERVAL_MS = 100;
34478
34498
  var DEFAULT_HARD_TIMEOUT_MS = 30 * 60 * 1000;
@@ -34485,7 +34505,14 @@ function resolveForegroundWaitMs(configured) {
34485
34505
  }
34486
34506
  return configured;
34487
34507
  }
34488
- var BASH_DESCRIPTION = `Hoisted bash tool with output compression, command rewriting to AFT tools, optional background execution, and PTY mode for interactive programs. By default, output is compressed; pass compressed: false for raw output. Pass background: true to spawn in the background and get a taskId for bash_status/bash_kill. Pass pty: true for interactive REPLs and drive them with bash_status({ outputMode: "screen" }) plus bash_write (pty implies background automatically). Use bash_watch to block on or register for pattern matches and exit events.`;
34508
+ function bashToolDescription(aftSearchRegistered, compressionOn, backgroundOn) {
34509
+ 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";
34510
+ const compression = compressionOn ? " Output is compressed by default; pass compressed: false for raw output." : "";
34511
+ 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.";
34512
+ return `Execute shell commands.${compression}${tasks} Use bash_watch to wait for output patterns or exit events.
34513
+
34514
+ 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}.`;
34515
+ }
34489
34516
  async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34490
34517
  const first = await bridgeCall(ctx, runtime, "bash", params, options);
34491
34518
  if (first.success !== false || first.code !== "permission_required")
@@ -34508,22 +34535,27 @@ async function withPermissionLoop(ctx, runtime, params, bridgeCall, options) {
34508
34535
  }
34509
34536
  return second;
34510
34537
  }
34511
- function createBashTool(ctx) {
34538
+ function createBashTool(ctx, aftSearchRegisteredOverride) {
34512
34539
  return {
34513
- description: BASH_DESCRIPTION,
34540
+ description: (() => {
34541
+ const cfg = resolveBashConfig(ctx.config);
34542
+ return bashToolDescription(false, cfg.compress, cfg.background);
34543
+ })(),
34514
34544
  args: {
34515
- command: z5.string().describe("Shell command to execute through AFT's unified bash schema. Supports normal shell syntax, pipes, redirection, and command rewriting to dedicated AFT tools when available."),
34545
+ command: z4.string().describe("Shell command to execute. Supports pipes, redirection, and normal shell syntax."),
34516
34546
  timeout: optionalInt(1, Number.MAX_SAFE_INTEGER).describe("Hard kill cap in milliseconds (positive integer). When omitted, the task can run up to 30 minutes. Foreground bash returns inline if the command finishes within ~8s (configurable via bash.foreground_wait_window_ms); otherwise it's automatically promoted to background and a completion reminder is delivered when the task actually finishes."),
34517
- workdir: z5.string().optional().describe("Working directory for command execution. Relative paths resolve through the bridge; defaults to the current tool context/project root when omitted."),
34518
- description: z5.string().optional().describe("Short 5-10 word human-readable summary shown in OpenCode UI metadata instead of raw shell syntax."),
34519
- background: z5.boolean().optional().describe("When true, spawn the command in the background and return a taskId for bash_status/bash_kill instead of waiting for completion. Defaults to false."),
34520
- compressed: z5.boolean().optional().describe("When true or omitted, return compressed output with noisy terminal control sequences reduced. Set to false for raw output."),
34521
- pty: z5.boolean().optional().describe('When true, spawn the command in a real PTY for interactive programs (python/node/bash REPLs, vim). Implies background: true automatically. Unavailable in subagent sessions. Inspect with bash_status({ taskId, outputMode: "screen" }) and drive interactively with bash_write — its input accepts either a string OR an array like [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ] for atomic text+key sequences.'),
34547
+ 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."),
34548
+ description: z4.string().optional().describe("Short 5-10 word human-readable summary shown in OpenCode UI metadata instead of raw shell syntax."),
34549
+ 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."),
34550
+ compressed: z4.boolean().optional().describe("When true or omitted, return compressed output with noisy terminal control sequences reduced. Set to false for raw output."),
34551
+ pty: z4.boolean().optional().describe('When true, spawn the command in a real PTY for interactive programs (python/node/bash REPLs, vim). Implies background: true automatically. Unavailable in subagent sessions. Inspect with bash_status({ taskId, outputMode: "screen" }) and drive interactively with bash_write — its input accepts either a string OR an array like [ "iHello", { key: "esc" }, ":wq", { key: "enter" } ] for atomic text+key sequences.'),
34522
34552
  ptyRows: optionalInt(1, 60).describe("PTY terminal height in rows — ignored when pty is false. Defaults to 24 when pty: true. Minimum 1, maximum 60."),
34523
34553
  ptyCols: optionalInt(1, 140).describe("PTY terminal width in columns — ignored when pty is false. Defaults to 80 when pty: true. Minimum 1, maximum 140.")
34524
34554
  },
34525
34555
  execute: async (args, context) => {
34526
34556
  const bashCfg = resolveBashConfig(ctx.config);
34557
+ const ctxAftSearchRegistered = ctx.aftSearchRegistered === true;
34558
+ const aftSearchRegistered = aftSearchRegisteredOverride ?? ctxAftSearchRegistered;
34527
34559
  let accumulatedOutput = "";
34528
34560
  const description = args.description;
34529
34561
  const metadata = context.metadata;
@@ -34591,7 +34623,7 @@ function createBashTool(ctx) {
34591
34623
  throw new Error(status.message ?? "bash_status failed");
34592
34624
  }
34593
34625
  if (isTerminalStatus(status.status)) {
34594
- const rendered2 = appendPipeStripNote(formatForegroundResult(status), pipeStrip.note);
34626
+ const rendered2 = maybeAppendGrepSearchHint(appendPipeStripNote(formatForegroundResult(status), pipeStrip.note), command, aftSearchRegistered);
34595
34627
  const metadataPayload2 = foregroundMetadata(description, status, rendered2);
34596
34628
  metadata?.(metadataPayload2);
34597
34629
  return { output: rendered2, title: uiTitle, metadata: metadataPayload2 };
@@ -34653,179 +34685,515 @@ function createBashTool(ctx) {
34653
34685
  metadata: metadataPayload
34654
34686
  };
34655
34687
  }
34656
- };
34688
+ };
34689
+ }
34690
+ function appendPipeStripNote(output, note) {
34691
+ return note ? `${output}
34692
+
34693
+ ${note}` : output;
34694
+ }
34695
+ function createBashStatusTool(ctx) {
34696
+ return {
34697
+ 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.",
34698
+ args: {
34699
+ taskId: z4.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047."),
34700
+ 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.")
34701
+ },
34702
+ execute: async (args, context) => {
34703
+ const taskId = args.taskId;
34704
+ const outputMode = args.outputMode;
34705
+ const data = await bashStatusSnapshot(ctx, context, taskId, outputMode);
34706
+ return await formatBashStatusText(context, taskId, data, outputMode);
34707
+ }
34708
+ };
34709
+ }
34710
+ function createBashKillTool(ctx) {
34711
+ return {
34712
+ description: "Terminate a running background bash task spawned with bash({ background: true }). Returns confirmation of kill or an error if the task already finished.",
34713
+ args: {
34714
+ taskId: z4.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047.")
34715
+ },
34716
+ execute: async (args, context) => {
34717
+ const data = await callBashBridge(ctx, context, "bash_kill", {
34718
+ task_id: args.taskId
34719
+ });
34720
+ if (data.success === false) {
34721
+ throw new Error(data.message ?? "bash_kill failed");
34722
+ }
34723
+ await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34724
+ if (data.kill_signaled === true) {
34725
+ return `Task ${args.taskId}: kill_signaled`;
34726
+ }
34727
+ return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34728
+ }
34729
+ };
34730
+ }
34731
+ async function bashStatusSnapshot(ctx, runtime, taskId, outputMode, options) {
34732
+ const data = await callBashBridge(ctx, runtime, "bash_status", { task_id: taskId, output_mode: outputMode }, options);
34733
+ if (data.success === false) {
34734
+ throw new Error(data.message ?? "bash_status failed");
34735
+ }
34736
+ return data;
34737
+ }
34738
+ async function formatBashStatusText(runtime, taskId, data, requestedOutputMode) {
34739
+ const status = data.status;
34740
+ const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
34741
+ const dur = typeof data.duration_ms === "number" ? ` ${Math.round(data.duration_ms / 1000)}s` : "";
34742
+ let text = `Task ${taskId}: ${status}${exit}${dur}`;
34743
+ if (data.mode === "pty") {
34744
+ text += await formatPtyStatus(runtime, taskId, data, requestedOutputMode);
34745
+ } else {
34746
+ const preview = data.output_preview;
34747
+ if (preview && status !== "running") {
34748
+ text += `
34749
+ ${preview}`;
34750
+ }
34751
+ if (status === "running") {
34752
+ text += `
34753
+ A completion reminder will be delivered automatically; don't poll.`;
34754
+ }
34755
+ }
34756
+ return text;
34757
+ }
34758
+ async function formatPtyStatus(runtime, taskId, data, requestedOutputMode) {
34759
+ const outputPath = data.output_path;
34760
+ if (!outputPath)
34761
+ return `
34762
+ [PTY output path unavailable]`;
34763
+ const key = ptyCacheKey(runtime, taskId);
34764
+ const { rows, cols } = ptyDimensions(data);
34765
+ const state = await getOrCreatePtyTerminal(key, outputPath, rows, cols);
34766
+ const raw = await readPtyBytes(state);
34767
+ const outputMode = requestedOutputMode ?? "screen";
34768
+ let suffix = "";
34769
+ if (outputMode === "raw") {
34770
+ suffix = raw.length > 0 ? `
34771
+ ${raw.toString("utf8")}` : "";
34772
+ } else if (outputMode === "both") {
34773
+ suffix = `
34774
+ ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
34775
+ } else {
34776
+ const screen = renderScreen(state, rows, cols);
34777
+ suffix = screen ? `
34778
+ ${screen}` : "";
34779
+ }
34780
+ if (data.status === "running") {
34781
+ suffix += `
34782
+ PTY task is still running. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to inspect, bash_write({ taskId: "${taskId}", input: "..." }) to send keystrokes.`;
34783
+ } else if (isTerminalStatus(data.status)) {
34784
+ await disposePtyTerminal(key);
34785
+ }
34786
+ return suffix;
34787
+ }
34788
+ function ptyDimensions(data) {
34789
+ const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
34790
+ const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
34791
+ return { rows, cols };
34792
+ }
34793
+ function ptyCacheKey(runtime, taskId) {
34794
+ return `${projectRootFor(runtime)}::${runtime.sessionID ?? "__default__"}::${taskId}`;
34795
+ }
34796
+ function preview(output) {
34797
+ return output.length <= METADATA_PREVIEW_LIMIT ? output : output.slice(-METADATA_PREVIEW_LIMIT);
34798
+ }
34799
+ function isTerminalStatus(status) {
34800
+ return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
34801
+ }
34802
+ function subagentGuidance(taskId) {
34803
+ return `
34804
+
34805
+ 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.`;
34806
+ }
34807
+ function formatBackgroundLaunch(taskId, isPty) {
34808
+ if (isPty) {
34809
+ 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.`;
34810
+ }
34811
+ return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
34812
+ }
34813
+ function formatPromotionMessage(taskId, timeout, waitWindowMs) {
34814
+ const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
34815
+ 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.`;
34816
+ }
34817
+ function formatSeconds(ms) {
34818
+ return `${Number((ms / 1000).toFixed(1))}s`;
34819
+ }
34820
+ function formatForegroundResult(data) {
34821
+ const output = data.output_preview ?? "";
34822
+ const outputPath = data.output_path;
34823
+ const truncated = data.output_truncated === true;
34824
+ const status = data.status;
34825
+ const exit = data.exit_code;
34826
+ let rendered = output;
34827
+ if (truncated && outputPath) {
34828
+ rendered += `
34829
+ [output truncated; full output at ${outputPath}]`;
34830
+ }
34831
+ if (status === "timed_out") {
34832
+ rendered += `
34833
+ [command timed out]`;
34834
+ }
34835
+ if (typeof exit === "number" && exit !== 0) {
34836
+ rendered += `
34837
+ [exit code: ${exit}]`;
34838
+ }
34839
+ return rendered;
34840
+ }
34841
+ function foregroundMetadata(description, data, rendered) {
34842
+ const outputPath = data.output_path;
34843
+ return {
34844
+ description,
34845
+ output: preview(rendered),
34846
+ exit: data.exit_code,
34847
+ truncated: data.output_truncated,
34848
+ ...outputPath ? { outputPath } : {}
34849
+ };
34850
+ }
34851
+ function sleep(ms) {
34852
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
34853
+ }
34854
+ function getCallID(ctx) {
34855
+ const c = ctx;
34856
+ return c.callID ?? c.callId ?? c.call_id;
34857
+ }
34858
+ function shortenCommand(command) {
34859
+ const collapsed = command.replace(/\s+/g, " ").trim();
34860
+ return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 77)}...`;
34861
+ }
34862
+
34863
+ // src/tools/conflicts.ts
34864
+ import { tool as tool5 } from "@opencode-ai/plugin";
34865
+ var z5 = tool5.schema;
34866
+ function conflictTools(ctx) {
34867
+ return {
34868
+ aft_conflicts: {
34869
+ 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).",
34870
+ args: {
34871
+ 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()
34872
+ },
34873
+ execute: async (args, context) => {
34874
+ const params = {};
34875
+ if (!isEmptyParam(args?.path)) {
34876
+ const expanded = expandTilde(String(args.path));
34877
+ const projectRoot = await resolveProjectRoot(ctx, context);
34878
+ const resolved = resolvePathFromProjectRoot(projectRoot, expanded);
34879
+ const denied = await assertExternalDirectoryPermission(context, resolved, {
34880
+ kind: "directory"
34881
+ });
34882
+ if (denied)
34883
+ return permissionDeniedResponse(denied);
34884
+ params.path = resolved;
34885
+ }
34886
+ const response = await callBridge(ctx, context, "git_conflicts", params);
34887
+ if (response.success === false) {
34888
+ throw new Error(response.message || "git_conflicts failed");
34889
+ }
34890
+ return response.text;
34891
+ }
34892
+ }
34893
+ };
34894
+ }
34895
+
34896
+ // src/tools/hoisted.ts
34897
+ import * as fs6 from "node:fs";
34898
+ import * as path4 from "node:path";
34899
+ import { tool as tool8 } from "@opencode-ai/plugin";
34900
+
34901
+ // src/patch-parser.ts
34902
+ function stripHeredoc(input) {
34903
+ const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
34904
+ return heredocMatch ? heredocMatch[2] : input;
34905
+ }
34906
+ function parsePatchHeader(lines, startIdx) {
34907
+ const line = lines[startIdx];
34908
+ if (line.startsWith("*** Add File:")) {
34909
+ const filePath = line.slice("*** Add File:".length).trim();
34910
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34911
+ }
34912
+ if (line.startsWith("*** Delete File:")) {
34913
+ const filePath = line.slice("*** Delete File:".length).trim();
34914
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
34915
+ }
34916
+ if (line.startsWith("*** Update File:")) {
34917
+ const filePath = line.slice("*** Update File:".length).trim();
34918
+ let movePath;
34919
+ let nextIdx = startIdx + 1;
34920
+ if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
34921
+ movePath = lines[nextIdx].slice("*** Move to:".length).trim();
34922
+ nextIdx++;
34923
+ }
34924
+ return filePath ? { filePath, movePath, nextIdx } : null;
34925
+ }
34926
+ return null;
34657
34927
  }
34658
- function appendPipeStripNote(output, note) {
34659
- return note ? `${output}
34660
-
34661
- ${note}` : output;
34928
+ function parseAddFileContent(lines, startIdx) {
34929
+ let content = "";
34930
+ let i = startIdx;
34931
+ while (i < lines.length && !lines[i].startsWith("***")) {
34932
+ if (lines[i].startsWith("+")) {
34933
+ content += `${lines[i].substring(1)}
34934
+ `;
34935
+ }
34936
+ i++;
34937
+ }
34938
+ if (content.endsWith(`
34939
+ `)) {
34940
+ content = content.slice(0, -1);
34941
+ }
34942
+ return { content, nextIdx: i };
34662
34943
  }
34663
- function createBashStatusTool(ctx) {
34664
- return {
34665
- description: "Read-only snapshot of a background or PTY bash task's current state and output. Returns immediately. Never waits. Use bash_watch to block on or register for pattern matches and exit events.",
34666
- args: {
34667
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047."),
34668
- outputMode: z5.enum(["screen", "raw", "both"]).optional().describe("PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.")
34669
- },
34670
- execute: async (args, context) => {
34671
- const taskId = args.taskId;
34672
- const outputMode = args.outputMode;
34673
- const data = await bashStatusSnapshot(ctx, context, taskId, outputMode);
34674
- return await formatBashStatusText(context, taskId, data, outputMode);
34944
+ function parseUpdateFileChunks(lines, startIdx) {
34945
+ const chunks = [];
34946
+ let i = startIdx;
34947
+ while (i < lines.length && !lines[i].startsWith("***")) {
34948
+ if (lines[i].startsWith("@@")) {
34949
+ const contextLine = lines[i].substring(2).trim();
34950
+ i++;
34951
+ const oldLines = [];
34952
+ const newLines = [];
34953
+ let isEndOfFile = false;
34954
+ while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
34955
+ const changeLine = lines[i];
34956
+ if (changeLine === "*** End of File") {
34957
+ isEndOfFile = true;
34958
+ i++;
34959
+ break;
34960
+ }
34961
+ if (changeLine.startsWith(" ")) {
34962
+ const content = changeLine.substring(1);
34963
+ oldLines.push(content);
34964
+ newLines.push(content);
34965
+ } else if (changeLine.startsWith("-")) {
34966
+ oldLines.push(changeLine.substring(1));
34967
+ } else if (changeLine.startsWith("+")) {
34968
+ newLines.push(changeLine.substring(1));
34969
+ }
34970
+ i++;
34971
+ }
34972
+ chunks.push({
34973
+ old_lines: oldLines,
34974
+ new_lines: newLines,
34975
+ change_context: contextLine || undefined,
34976
+ is_end_of_file: isEndOfFile || undefined
34977
+ });
34978
+ } else {
34979
+ i++;
34675
34980
  }
34676
- };
34981
+ }
34982
+ return { chunks, nextIdx: i };
34677
34983
  }
34678
- function createBashKillTool(ctx) {
34679
- return {
34680
- description: "Terminate a running background bash task spawned with bash({ background: true }). Returns confirmation of kill or an error if the task already finished.",
34681
- args: {
34682
- taskId: z5.string().describe("Background task ID returned by bash({ background: true }), e.g. bash-6b454047.")
34683
- },
34684
- execute: async (args, context) => {
34685
- const data = await callBashBridge(ctx, context, "bash_kill", {
34686
- task_id: args.taskId
34984
+ var MAX_PATCH_SIZE = 1024 * 1024;
34985
+ var MAX_HUNKS = 500;
34986
+ function parsePatch(patchText) {
34987
+ if (patchText.length > MAX_PATCH_SIZE) {
34988
+ throw new Error(`Patch too large: ${patchText.length} bytes exceeds limit of ${MAX_PATCH_SIZE} bytes`);
34989
+ }
34990
+ const cleaned = stripHeredoc(patchText.trim());
34991
+ const lines = cleaned.split(`
34992
+ `);
34993
+ const hunks = [];
34994
+ const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
34995
+ const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
34996
+ if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
34997
+ throw new Error("Invalid patch format: missing *** Begin Patch / *** End Patch markers");
34998
+ }
34999
+ let i = beginIdx + 1;
35000
+ while (i < endIdx) {
35001
+ const header = parsePatchHeader(lines, i);
35002
+ if (!header) {
35003
+ i++;
35004
+ continue;
35005
+ }
35006
+ if (hunks.length >= MAX_HUNKS) {
35007
+ throw new Error(`Patch exceeds maximum of ${MAX_HUNKS} file operations`);
35008
+ }
35009
+ if (lines[i].startsWith("*** Add File:")) {
35010
+ const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
35011
+ hunks.push({ type: "add", path: header.filePath, contents: content });
35012
+ i = nextIdx;
35013
+ } else if (lines[i].startsWith("*** Delete File:")) {
35014
+ hunks.push({ type: "delete", path: header.filePath });
35015
+ i = header.nextIdx;
35016
+ } else if (lines[i].startsWith("*** Update File:")) {
35017
+ const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
35018
+ hunks.push({
35019
+ type: "update",
35020
+ path: header.filePath,
35021
+ move_path: header.movePath,
35022
+ chunks
34687
35023
  });
34688
- if (data.success === false) {
34689
- throw new Error(data.message ?? "bash_kill failed");
35024
+ i = nextIdx;
35025
+ } else {
35026
+ i++;
35027
+ }
35028
+ }
35029
+ return hunks;
35030
+ }
35031
+ function normalizeUnicode(str) {
35032
+ 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, " ");
35033
+ }
35034
+ function normalizeIndent(str) {
35035
+ return str.replace(/^[\t ]+/, (m) => " ".repeat(m.length));
35036
+ }
35037
+ function tryMatch(lines, pattern, startIndex, compare, eof) {
35038
+ if (eof) {
35039
+ const fromEnd = lines.length - pattern.length;
35040
+ if (fromEnd >= startIndex) {
35041
+ let matches = true;
35042
+ for (let j = 0;j < pattern.length; j++) {
35043
+ if (!compare(lines[fromEnd + j], pattern[j])) {
35044
+ matches = false;
35045
+ break;
35046
+ }
34690
35047
  }
34691
- await disposePtyTerminal(ptyCacheKey(context, args.taskId));
34692
- if (data.kill_signaled === true) {
34693
- return `Task ${args.taskId}: kill_signaled`;
35048
+ if (matches)
35049
+ return fromEnd;
35050
+ }
35051
+ }
35052
+ for (let i = startIndex;i <= lines.length - pattern.length; i++) {
35053
+ let matches = true;
35054
+ for (let j = 0;j < pattern.length; j++) {
35055
+ if (!compare(lines[i + j], pattern[j])) {
35056
+ matches = false;
35057
+ break;
34694
35058
  }
34695
- return `Task ${args.taskId}: ${String(data.status ?? "killed")}`;
34696
35059
  }
34697
- };
34698
- }
34699
- async function bashStatusSnapshot(ctx, runtime, taskId, outputMode, options) {
34700
- const data = await callBashBridge(ctx, runtime, "bash_status", { task_id: taskId, output_mode: outputMode }, options);
34701
- if (data.success === false) {
34702
- throw new Error(data.message ?? "bash_status failed");
35060
+ if (matches)
35061
+ return i;
34703
35062
  }
34704
- return data;
35063
+ return -1;
34705
35064
  }
34706
- async function formatBashStatusText(runtime, taskId, data, requestedOutputMode) {
34707
- const status = data.status;
34708
- const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
34709
- const dur = typeof data.duration_ms === "number" ? ` ${Math.round(data.duration_ms / 1000)}s` : "";
34710
- let text = `Task ${taskId}: ${status}${exit}${dur}`;
34711
- if (data.mode === "pty") {
34712
- text += await formatPtyStatus(runtime, taskId, data, requestedOutputMode);
34713
- } else {
34714
- const preview = data.output_preview;
34715
- if (preview && status !== "running") {
34716
- text += `
34717
- ${preview}`;
35065
+ function seekSequenceTiered(lines, pattern, startIndex, eof = false) {
35066
+ if (pattern.length === 0)
35067
+ return null;
35068
+ const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
35069
+ if (exact !== -1)
35070
+ return { found: exact, tier: "exact" };
35071
+ const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
35072
+ if (rstrip !== -1)
35073
+ return { found: rstrip, tier: "rstrip" };
35074
+ const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
35075
+ if (trim !== -1)
35076
+ return { found: trim, tier: "trim" };
35077
+ const indent = tryMatch(lines, pattern, startIndex, (a, b) => normalizeIndent(a).trimEnd() === normalizeIndent(b).trimEnd(), eof);
35078
+ if (indent !== -1)
35079
+ return { found: indent, tier: "indent" };
35080
+ const unicode = tryMatch(lines, pattern, startIndex, (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), eof);
35081
+ if (unicode !== -1)
35082
+ return { found: unicode, tier: "unicode" };
35083
+ return null;
35084
+ }
35085
+ function seekSequence(lines, pattern, startIndex, eof = false) {
35086
+ const r = seekSequenceTiered(lines, pattern, startIndex, eof);
35087
+ return r ? r.found : -1;
35088
+ }
35089
+ function findClosestPartialMatch(lines, pattern) {
35090
+ if (pattern.length === 0 || lines.length === 0)
35091
+ return null;
35092
+ 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());
35093
+ const candidates = [];
35094
+ for (let i = 0;i < lines.length && candidates.length < 16; i++) {
35095
+ if (compareAny(lines[i], pattern[0]))
35096
+ candidates.push(i);
35097
+ }
35098
+ if (candidates.length === 0)
35099
+ return null;
35100
+ let best = { lineNumber: -1, matchedLines: 0, firstDivergence: -1 };
35101
+ for (const start of candidates) {
35102
+ let matched = 0;
35103
+ for (let j = 0;j < pattern.length && start + j < lines.length; j++) {
35104
+ if (!compareAny(lines[start + j], pattern[j]))
35105
+ break;
35106
+ matched++;
34718
35107
  }
34719
- if (status === "running") {
34720
- text += `
34721
- A completion reminder will be delivered automatically; don't poll.`;
35108
+ if (matched > best.matchedLines) {
35109
+ best = {
35110
+ lineNumber: start + 1,
35111
+ matchedLines: matched,
35112
+ firstDivergence: matched
35113
+ };
34722
35114
  }
34723
35115
  }
34724
- return text;
35116
+ return best.lineNumber === -1 ? null : best;
34725
35117
  }
34726
- async function formatPtyStatus(runtime, taskId, data, requestedOutputMode) {
34727
- const outputPath = data.output_path;
34728
- if (!outputPath)
34729
- return `
34730
- [PTY output path unavailable]`;
34731
- const key = ptyCacheKey(runtime, taskId);
34732
- const { rows, cols } = ptyDimensions(data);
34733
- const state = await getOrCreatePtyTerminal(key, outputPath, rows, cols);
34734
- const raw = await readPtyBytes(state);
34735
- const outputMode = requestedOutputMode ?? "screen";
34736
- let suffix = "";
34737
- if (outputMode === "raw") {
34738
- suffix = raw.length > 0 ? `
34739
- ${raw.toString("utf8")}` : "";
34740
- } else if (outputMode === "both") {
34741
- suffix = `
34742
- ${JSON.stringify({ screen: renderScreen(state, rows, cols), raw: raw.toString("utf8") }, null, 2)}`;
34743
- } else {
34744
- const screen = renderScreen(state, rows, cols);
34745
- suffix = screen ? `
34746
- ${screen}` : "";
34747
- }
34748
- if (data.status === "running") {
34749
- suffix += `
34750
- PTY task is still running. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to inspect, bash_write({ taskId: "${taskId}", input: "..." }) to send keystrokes.`;
34751
- } else if (isTerminalStatus(data.status)) {
34752
- await disposePtyTerminal(key);
35118
+ function applyUpdateChunks(originalContent, filePath, chunks) {
35119
+ const originalLines = originalContent.split(`
35120
+ `);
35121
+ if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
35122
+ originalLines.pop();
34753
35123
  }
34754
- return suffix;
34755
- }
34756
- function ptyDimensions(data) {
34757
- const rows = typeof data.pty_rows === "number" ? data.pty_rows : 24;
34758
- const cols = typeof data.pty_cols === "number" ? data.pty_cols : 80;
34759
- return { rows, cols };
34760
- }
34761
- function ptyCacheKey(runtime, taskId) {
34762
- return `${projectRootFor(runtime)}::${runtime.sessionID ?? "__default__"}::${taskId}`;
34763
- }
34764
- function preview(output) {
34765
- return output.length <= METADATA_PREVIEW_LIMIT ? output : output.slice(-METADATA_PREVIEW_LIMIT);
34766
- }
34767
- function isTerminalStatus(status) {
34768
- return status === "completed" || status === "failed" || status === "killed" || status === "timed_out";
34769
- }
34770
- function subagentGuidance(taskId) {
34771
- return `
35124
+ const replacements = [];
35125
+ let lineIndex = 0;
35126
+ for (const chunk of chunks) {
35127
+ if (chunk.change_context) {
35128
+ const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
35129
+ if (contextIdx === -1) {
35130
+ throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
35131
+ }
35132
+ lineIndex = contextIdx + 1;
35133
+ }
35134
+ if (chunk.old_lines.length === 0) {
35135
+ let insertionIdx;
35136
+ if (chunk.change_context) {
35137
+ insertionIdx = lineIndex;
35138
+ } else if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
35139
+ insertionIdx = originalLines.length - 1;
35140
+ } else {
35141
+ insertionIdx = originalLines.length;
35142
+ }
35143
+ replacements.push([insertionIdx, 0, chunk.new_lines]);
35144
+ continue;
35145
+ }
35146
+ let pattern = chunk.old_lines;
35147
+ let newSlice = chunk.new_lines;
35148
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
35149
+ if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
35150
+ pattern = pattern.slice(0, -1);
35151
+ if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
35152
+ newSlice = newSlice.slice(0, -1);
35153
+ }
35154
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
35155
+ }
35156
+ if (found !== -1) {
35157
+ replacements.push([found, pattern.length, newSlice]);
35158
+ lineIndex = found + pattern.length;
35159
+ } else {
35160
+ const newSliceTrimmed = newSlice.filter((line) => line.trim().length > 0);
35161
+ const alreadyApplied = newSliceTrimmed.length > 0 && seekSequence(originalLines, newSliceTrimmed, 0, chunk.is_end_of_file) !== -1;
35162
+ const closest = findClosestPartialMatch(originalLines, pattern);
35163
+ let closestHint = "";
35164
+ if (closest && closest.matchedLines > 0) {
35165
+ const fileLineNo = closest.lineNumber + closest.firstDivergence;
35166
+ const expectedLine = pattern[closest.firstDivergence];
35167
+ const actualLine = fileLineNo - 1 < originalLines.length ? originalLines[fileLineNo - 1] : "<EOF>";
35168
+ closestHint = `
34772
35169
 
34773
- NOTE (subagent session): Continue with other work if you have it. If you don't, call bash_watch({ taskId: "${taskId}", timeoutMs: 60000 }) to wait for completion before returning to the parent. Subagents don't survive turn-end and won't receive the completion reminder.`;
34774
- }
34775
- function formatBackgroundLaunch(taskId, isPty) {
34776
- if (isPty) {
34777
- return `PTY task started: ${taskId}. Use bash_status({ taskId: "${taskId}", outputMode: "screen" }) to see the visible terminal, bash_write({ taskId: "${taskId}", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits.`;
34778
- }
34779
- return `Background task started: ${taskId}. A completion reminder will be delivered automatically; don't poll bash_status.`;
34780
- }
34781
- function formatPromotionMessage(taskId, timeout, waitWindowMs) {
34782
- const waited = timeout !== undefined ? Math.min(timeout, waitWindowMs) : waitWindowMs;
34783
- return `Foreground bash didn't finish within ${formatSeconds(waited)} and was promoted to background: ${taskId}. A completion reminder will be delivered automatically; use bash_status({ taskId: "${taskId}" }) to inspect output or bash_kill({ taskId: "${taskId}" }) to terminate.`;
34784
- }
34785
- function formatSeconds(ms) {
34786
- return `${Number((ms / 1000).toFixed(1))}s`;
34787
- }
34788
- function formatForegroundResult(data) {
34789
- const output = data.output_preview ?? "";
34790
- const outputPath = data.output_path;
34791
- const truncated = data.output_truncated === true;
34792
- const status = data.status;
34793
- const exit = data.exit_code;
34794
- let rendered = output;
34795
- if (truncated && outputPath) {
34796
- rendered += `
34797
- [output truncated; full output at ${outputPath}]`;
35170
+ Closest match starts at line ${closest.lineNumber} ` + `(${closest.matchedLines} of ${pattern.length} lines matched).
35171
+ ` + `First divergence at line ${fileLineNo}:
35172
+ ` + ` expected: ${JSON.stringify(expectedLine)}
35173
+ ` + ` actual: ${JSON.stringify(actualLine)}`;
35174
+ }
35175
+ const triedTiers = "exact, trimEnd, trim, indent (tab/space), unicode";
35176
+ const alreadyAppliedHint = alreadyApplied ? `
35177
+
35178
+ 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." : "";
35179
+ throw new Error(`Failed to find expected lines in ${filePath}:
35180
+ ${chunk.old_lines.join(`
35181
+ `)}
35182
+
35183
+ ` + `Tried match tiers: ${triedTiers}.${closestHint}${alreadyAppliedHint}`);
35184
+ }
34798
35185
  }
34799
- if (status === "timed_out") {
34800
- rendered += `
34801
- [command timed out]`;
35186
+ replacements.sort((a, b) => a[0] - b[0]);
35187
+ const result = [...originalLines];
35188
+ for (let i = replacements.length - 1;i >= 0; i--) {
35189
+ const [startIdx, oldLen, newSegment] = replacements[i];
35190
+ result.splice(startIdx, oldLen, ...newSegment);
34802
35191
  }
34803
- if (typeof exit === "number" && exit !== 0) {
34804
- rendered += `
34805
- [exit code: ${exit}]`;
35192
+ if (result.length === 0 || result[result.length - 1] !== "") {
35193
+ result.push("");
34806
35194
  }
34807
- return rendered;
34808
- }
34809
- function foregroundMetadata(description, data, rendered) {
34810
- const outputPath = data.output_path;
34811
- return {
34812
- description,
34813
- output: preview(rendered),
34814
- exit: data.exit_code,
34815
- truncated: data.output_truncated,
34816
- ...outputPath ? { outputPath } : {}
34817
- };
34818
- }
34819
- function sleep(ms) {
34820
- return new Promise((resolve7) => setTimeout(resolve7, ms));
34821
- }
34822
- function getCallID(ctx) {
34823
- const c = ctx;
34824
- return c.callID ?? c.callId ?? c.call_id;
34825
- }
34826
- function shortenCommand(command) {
34827
- const collapsed = command.replace(/\s+/g, " ").trim();
34828
- return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 77)}...`;
35195
+ return result.join(`
35196
+ `);
34829
35197
  }
34830
35198
 
34831
35199
  // src/tools/bash_watch.ts
@@ -34890,6 +35258,12 @@ A notification will fire when the pattern matches or the task exits.`;
34890
35258
  const effectiveWaitMs = subagentForcedSync ? MAX_BASH_STATUS_WAIT_TIMEOUT_MS : Math.min(args.timeoutMs ?? DEFAULT_BASH_STATUS_WAIT_TIMEOUT_MS, MAX_BASH_STATUS_WAIT_TIMEOUT_MS);
34891
35259
  const data = await waitForBashStatus(ctx, context, taskId, undefined, waitFor, effectiveWaitMs);
34892
35260
  const waited = data.waited;
35261
+ if (waited?.reason === "user_message") {
35262
+ const convertedText = await convertToAsyncWatchOnAbort(ctx, context, taskId, waitFor, args.once !== false);
35263
+ const metadata2 = context.metadata;
35264
+ metadata2?.({ taskId, status: data.status, waited, convertedToAsync: true });
35265
+ return convertedText;
35266
+ }
34893
35267
  const metadata = context.metadata;
34894
35268
  if (waited)
34895
35269
  metadata?.({ taskId, status: data.status, waited });
@@ -34897,6 +35271,31 @@ A notification will fire when the pattern matches or the task exits.`;
34897
35271
  }
34898
35272
  };
34899
35273
  }
35274
+ async function convertToAsyncWatchOnAbort(ctx, context, taskId, waitFor, once) {
35275
+ if (!waitFor) {
35276
+ return `Sync watch for task ${taskId} was interrupted because you sent a message. ` + `The task is still running in the background. A completion reminder will be ` + `delivered automatically when the task exits; don't poll bash_status.`;
35277
+ }
35278
+ const notifyParams = {
35279
+ task_id: taskId,
35280
+ once
35281
+ };
35282
+ if (waitFor.kind === "regex")
35283
+ notifyParams.regex = waitFor.source;
35284
+ else
35285
+ notifyParams.pattern = waitFor.value;
35286
+ markExplicitControl(context.sessionID, taskId, false);
35287
+ try {
35288
+ const registered = await callBashBridge(ctx, context, "bash_notify", notifyParams);
35289
+ if (registered.success === false) {
35290
+ unmarkExplicitControl(context.sessionID, taskId);
35291
+ return `Sync watch for task ${taskId} was interrupted because you sent a message. ` + `Auto-registering an async watch failed (${String(registered.message ?? "unknown error")}). ` + `The task is still running in the background. A completion reminder will be ` + `delivered automatically when the task exits.`;
35292
+ }
35293
+ return `Sync watch for task ${taskId} was interrupted because you sent a message. ` + `The wait has been converted to an async watch (${registered.watch_id}). ` + `A notification will fire when the pattern matches or the task exits.`;
35294
+ } catch (err) {
35295
+ unmarkExplicitControl(context.sessionID, taskId);
35296
+ return `Sync watch for task ${taskId} was interrupted because you sent a message. ` + `Auto-registering an async watch failed (${err instanceof Error ? err.message : String(err)}). ` + `The task is still running in the background. A completion reminder will be ` + `delivered automatically when the task exits.`;
35297
+ }
35298
+ }
34900
35299
  function formatWatchResultText(taskId, data, waited) {
34901
35300
  const status = data.status;
34902
35301
  const exit = typeof data.exit_code === "number" ? ` (exit ${data.exit_code})` : "";
@@ -34936,6 +35335,7 @@ async function waitForBashStatus(ctx, runtime, taskId, outputMode, waitFor, effe
34936
35335
  let scanText = "";
34937
35336
  let scanBaseOffset = 0;
34938
35337
  const bridgeOptions = {};
35338
+ clearSyncWatchAbort(runtime.sessionID);
34939
35339
  markTaskWaiting(runtime.sessionID, taskId);
34940
35340
  let sawTerminal = false;
34941
35341
  try {
@@ -34974,6 +35374,9 @@ async function waitForBashStatus(ctx, runtime, taskId, outputMode, waitFor, effe
34974
35374
  await markBgCompletionDelivered({ ctx, directory: projectRootFor(runtime), sessionID: runtime.sessionID }, taskId);
34975
35375
  return withWaited(data, { reason: "exited", elapsed_ms: Date.now() - startedAt });
34976
35376
  }
35377
+ if (isSyncWatchAborted(runtime.sessionID)) {
35378
+ return withWaited(data, { reason: "user_message", elapsed_ms: Date.now() - startedAt });
35379
+ }
34977
35380
  if (Date.now() >= deadline) {
34978
35381
  return withWaited(data, { reason: "timeout", elapsed_ms: Date.now() - startedAt });
34979
35382
  }
@@ -35339,7 +35742,6 @@ function inferBeforeStart(ops, from, beforeLen) {
35339
35742
  return beforeLen;
35340
35743
  }
35341
35744
  var z8 = tool8.schema;
35342
- var DIAGNOSTICS_PARAM_DESCRIPTION = "When true, wait up to 3 seconds for fresh LSP diagnostics on the edited file and include them in the result. Defaults to the configured `lsp.diagnostics_on_edit` value (false unless configured); per-call true/false overrides. Use aft_inspect to check diagnostics across a batch of edits or before tests/commits.";
35343
35745
  function diagnosticsOnEditDefault(ctx) {
35344
35746
  return ctx.config.lsp?.diagnostics_on_edit ?? false;
35345
35747
  }
@@ -35471,30 +35873,14 @@ function createReadTool(ctx) {
35471
35873
  };
35472
35874
  }
35473
35875
  function getWriteDescription(editToolName) {
35474
- return `Write content to a file, creating it (and parent directories) if needed.
35475
-
35476
- Automatically creates parent directories. Backs up existing files before overwriting.
35477
- If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
35478
- is auto-formatted after writing. Edits return as soon as the write completes unless
35479
- the configured \`lsp.diagnostics_on_edit\` default or a per-call \`diagnostics: true\`
35480
- asks for legacy sync-wait behavior. Call \`aft_inspect\` afterward to check
35481
- diagnostics across a batch of edits.
35482
-
35483
- **Behavior:**
35484
- - Creates parent directories automatically (no need to mkdir first)
35485
- - Existing files are backed up before overwriting (recoverable via aft_safety undo)
35486
- - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
35487
- - LSP diagnostics follow \`lsp.diagnostics_on_edit\` by default; pass \`diagnostics\` to override per call
35488
- - Use this for creating new files or completely replacing file contents
35489
- - For partial edits (find/replace), use the \`${editToolName}\` tool instead`;
35876
+ return `Write content to a file, creating it and parent directories automatically. Existing files are backed up before overwriting (undo via aft_safety) and auto-formatted when the project has a formatter configured. Use it to create files or replace whole contents; for partial edits, use the \`${editToolName}\` tool.`;
35490
35877
  }
35491
35878
  function createWriteTool(ctx, editToolName = "edit") {
35492
35879
  return {
35493
35880
  description: getWriteDescription(editToolName),
35494
35881
  args: {
35495
35882
  filePath: z8.string().describe("Path to the file to write (absolute or relative to project root)"),
35496
- content: z8.string().describe("The full content to write to the file"),
35497
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
35883
+ content: z8.string().describe("The full content to write to the file")
35498
35884
  },
35499
35885
  execute: async (args, context) => {
35500
35886
  const file2 = args.filePath;
@@ -35517,12 +35903,15 @@ function createWriteTool(ctx, editToolName = "edit") {
35517
35903
  file: filePath,
35518
35904
  content,
35519
35905
  create_dirs: true,
35520
- diagnostics: args.diagnostics ?? diagnosticsOnEditDefault(ctx),
35906
+ diagnostics: diagnosticsOnEditDefault(ctx),
35521
35907
  include_diff_content: true
35522
35908
  });
35523
35909
  if (data.success === false) {
35524
35910
  throw new Error(data.message || "write failed");
35525
35911
  }
35912
+ if (data.rolled_back === true) {
35913
+ return "Write rolled back: the content produced invalid syntax, so the file was left unchanged.";
35914
+ }
35526
35915
  let output = data.created ? "Created new file." : "File updated.";
35527
35916
  if (data.formatted)
35528
35917
  output += " Auto-formatted.";
@@ -35621,7 +36010,6 @@ Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldS
35621
36010
  - Auto-formats using project formatter if configured
35622
36011
  - Tree-sitter syntax validation on all edits
35623
36012
  - Symbol replace includes decorators, attributes, and doc comments in range
35624
- - Edits return as soon as the write completes unless \`lsp.diagnostics_on_edit\` or a per-call \`diagnostics: true\` requests legacy sync-wait behavior. Call \`aft_inspect\` afterward to check diagnostics across a batch of edits.
35625
36013
  - Response is a JSON string for the selected edit mode; key fields include success, diff, backup_id, syntax_valid, and mode-specific fields.`;
35626
36014
  }
35627
36015
  function createEditTool(ctx, writeToolName = "write") {
@@ -35636,8 +36024,7 @@ function createEditTool(ctx, writeToolName = "write") {
35636
36024
  symbol: z8.string().optional().describe("Named symbol to replace (function, class, type)"),
35637
36025
  content: z8.string().optional().describe("Replacement content for symbol mode. For whole-file writes, use the `write` tool."),
35638
36026
  appendContent: z8.string().optional().describe("Text to append to the end of filePath; creates the file if needed"),
35639
- edits: z8.array(z8.record(z8.string(), z8.unknown())).optional().describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }"),
35640
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
36027
+ edits: z8.array(z8.record(z8.string(), z8.unknown())).optional().describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }")
35641
36028
  },
35642
36029
  execute: async (args, context) => {
35643
36030
  const argsRecord = args;
@@ -35703,7 +36090,7 @@ function createEditTool(ctx, writeToolName = "write") {
35703
36090
  const hint = typeof args.content === "string" ? ` To write the whole file, use the '${writeToolName}' tool. To edit existing content, provide 'oldString' (and optionally 'newString'), 'symbol' + 'content', or an 'edits' array.` : " Provide 'oldString' (+ optional 'newString'), 'symbol' + 'content', or an 'edits' array.";
35704
36091
  throw new Error(`edit: no edit mode resolved from arguments.${hint}`);
35705
36092
  }
35706
- params.diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36093
+ params.diagnostics = diagnosticsOnEditDefault(ctx);
35707
36094
  params.include_diff_content = true;
35708
36095
  const data = await callBridge(ctx, context, command, params);
35709
36096
  if (data.success === false) {
@@ -35825,12 +36212,11 @@ function createApplyPatchTool(ctx) {
35825
36212
  return {
35826
36213
  description: APPLY_PATCH_DESCRIPTION,
35827
36214
  args: {
35828
- patchText: z8.string().describe("The full patch text including Begin/End markers"),
35829
- diagnostics: z8.boolean().optional().describe(DIAGNOSTICS_PARAM_DESCRIPTION)
36215
+ patchText: z8.string().describe("The full patch text including Begin/End markers")
35830
36216
  },
35831
36217
  execute: async (args, context) => {
35832
36218
  const patchText = args.patchText;
35833
- const diagnostics = args.diagnostics ?? diagnosticsOnEditDefault(ctx);
36219
+ const diagnostics = diagnosticsOnEditDefault(ctx);
35834
36220
  if (!patchText)
35835
36221
  throw new Error("'patchText' is required");
35836
36222
  let hunks;
@@ -35918,6 +36304,9 @@ function createApplyPatchTool(ctx) {
35918
36304
  if (writeResult.success === false) {
35919
36305
  throw new Error(writeResult.message ?? "write failed");
35920
36306
  }
36307
+ if (writeResult.rolled_back === true) {
36308
+ throw new Error("produced invalid syntax (rolled back)");
36309
+ }
35921
36310
  const wrDiff = writeResult.diff;
35922
36311
  perFileDiffs.push({
35923
36312
  filePath,
@@ -35979,6 +36368,9 @@ function createApplyPatchTool(ctx) {
35979
36368
  if (writeResult.success === false) {
35980
36369
  throw new Error(writeResult.message ?? "write failed");
35981
36370
  }
36371
+ if (writeResult.rolled_back === true) {
36372
+ throw new Error("produced invalid syntax (rolled back)");
36373
+ }
35982
36374
  const diags = writeResult.lsp_diagnostics;
35983
36375
  if (diags && diags.length > 0) {
35984
36376
  const errors3 = diags.filter((d) => d.severity === "error");
@@ -37049,17 +37441,12 @@ var z13 = tool13.schema;
37049
37441
  function refactoringTools(ctx) {
37050
37442
  return {
37051
37443
  aft_refactor: {
37052
- description: `Workspace-wide refactoring operations that update imports and references across files.
37444
+ description: `Workspace-wide refactoring that updates imports and references across files.
37053
37445
 
37054
37446
  ` + `Ops:
37055
- ` + `- 'move': Move a top-level symbol to another file, updating all imports workspace-wide. Requires 'symbol', 'destination'. Creates a checkpoint before mutating. Only works on top-level exports (not nested functions or class methods).
37056
- ` + ` Note: This moves code symbols between files. To rename/move an entire file, use aft_move instead.
37057
- ` + `- 'extract': Extract a line range into a new function with auto-detected parameters. Requires 'name', 'startLine', 'endLine' (1-based, both inclusive). Supports TS/JS/TSX and Python.
37058
- ` + `- 'inline': Replace a function call with the function's body, substituting args for params. Requires 'symbol', 'callSiteLine' (1-based). Validates single-return constraint.
37059
-
37060
- ` + `Each op requires specific parameters — see parameter descriptions for requirements.
37061
-
37062
- ` + "All ops need 'filePath'. Use aft_safety checkpoint/undo before risky refactors.",
37447
+ ` + `- '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.
37448
+ ` + `- 'extract': extract a line range into a new function with auto-detected parameters (TS/JS/TSX, Python).
37449
+ ` + "- 'inline': replace a function call with the function's body.",
37063
37450
  args: {
37064
37451
  op: z13.enum(["move", "extract", "inline"]).describe("Refactoring operation"),
37065
37452
  filePath: z13.string().describe("Path to the source file (absolute or relative to project root)"),
@@ -37228,9 +37615,10 @@ function safetyTools(ctx) {
37228
37615
  if (Array.isArray(checkpointFiles)) {
37229
37616
  const projectRoot = await resolveProjectRoot(ctx, context);
37230
37617
  const uniqueParents = new Set;
37231
- for (const file2 of checkpointFiles) {
37232
- if (typeof file2 !== "string")
37618
+ for (const rawFile of checkpointFiles) {
37619
+ if (typeof rawFile !== "string")
37233
37620
  continue;
37621
+ const file2 = expandTilde(rawFile);
37234
37622
  const abs = path5.isAbsolute(file2) ? file2 : path5.resolve(projectRoot, file2);
37235
37623
  const parent = path5.dirname(abs);
37236
37624
  if (uniqueParents.has(parent))
@@ -37270,16 +37658,17 @@ function safetyTools(ctx) {
37270
37658
  const params = {};
37271
37659
  if (args.name !== undefined)
37272
37660
  params.name = args.name;
37273
- const payloadFiles = coerceStringArray(args.files);
37661
+ const payloadFiles = coerceStringArray(args.files).map(expandTilde);
37662
+ const filePathArg = typeof args.filePath === "string" ? expandTilde(args.filePath) : undefined;
37274
37663
  if (op === "checkpoint") {
37275
37664
  if (payloadFiles.length > 0) {
37276
37665
  params.files = payloadFiles;
37277
- } else if (args.filePath !== undefined) {
37278
- params.files = [args.filePath];
37666
+ } else if (filePathArg !== undefined) {
37667
+ params.files = [filePathArg];
37279
37668
  }
37280
37669
  } else {
37281
- if (args.filePath !== undefined)
37282
- params.file = args.filePath;
37670
+ if (filePathArg !== undefined)
37671
+ params.file = filePathArg;
37283
37672
  if (payloadFiles.length > 0)
37284
37673
  params.files = payloadFiles;
37285
37674
  }
@@ -37523,21 +37912,9 @@ function arg3(schema) {
37523
37912
  function semanticTools(ctx) {
37524
37913
  const searchTool = {
37525
37914
  description: [
37526
- "Find code with unified semantic, lexical, literal, and regex search. Returns ranked symbol/file results or exact matching lines, with routing metadata.",
37527
- "",
37528
- "When to reach for it:",
37529
- "- Exploring an unfamiliar area: 'where is rate limiting handled', 'how does auth flow work'",
37530
- "- Concept doesn't appear as a literal string: 'retry logic', 'cache invalidation', 'graceful shutdown'",
37531
- "- Filename-shaped concepts: 'the bridge spawn helper', 'the session detection module'",
37532
- "- Regex-shaped or exact text queries when you want AFT to classify and route automatically",
37533
- "- You know roughly what the function does but not what it's named",
37534
- "",
37535
- "When NOT to use:",
37536
- "- You need exhaustive literal enumeration → use grep directly",
37537
- "- You want the file/module structure → use aft_outline",
37538
- "- You're following a call chain → use aft_callgraph",
37915
+ "Search code with one tool: concepts, identifiers, error strings, regex, literals, and filenames are auto-routed to the right engine and returned ranked. Use it for any code search — including when you only know what the code does, not what it's named ('where is rate limiting handled', 'retry logic', '^export', 'Cargo.lock').",
37539
37916
  "",
37540
- "Set hint to 'regex', 'literal', 'semantic', or 'auto' to override or document routing intent."
37917
+ "Set hint to 'regex', 'literal', or 'semantic' to force a lane."
37541
37918
  ].join(`
37542
37919
  `),
37543
37920
  args: {
@@ -37581,118 +37958,8 @@ ${note}` : response.text;
37581
37958
  };
37582
37959
  }
37583
37960
 
37584
- // src/tools/structure.ts
37585
- import { tool as tool16 } from "@opencode-ai/plugin";
37586
- var z16 = tool16.schema;
37587
- function structureTools(ctx) {
37588
- return {
37589
- aft_transform: {
37590
- description: `Scope-aware structural code transformations with correct indentation.
37591
-
37592
- ` + `Language-specific: add_derive (Rust), add_struct_tags (Go), add_decorator (Python), wrap_try_catch (TS/JS), add_member (any language).
37593
-
37594
- ` + `Ops:
37595
- ` + `- 'add_member': Insert method/field into class, struct, or impl block. Requires 'container' (container name) and 'code'. Optional 'position'.
37596
- ` + `- 'add_derive': Add Rust derive macros to a struct/enum. Requires 'target' and 'derives' array. Deduplicates existing derives.
37597
- ` + `- 'wrap_try_catch': Wrap a TS/JS function body in try/catch. Requires 'target' (function name). Optional 'catchBody'.
37598
- ` + `- 'add_decorator': Add Python decorator to function/class. Requires 'target' and 'decorator' (without @). Optional 'position'.
37599
- ` + `- 'add_struct_tags': Add/update Go struct field tags. Requires 'target' (struct name), 'field', 'tag', 'value'.
37600
-
37601
- ` + "Each op requires specific parameters — see parameter descriptions for requirements. Use aft_safety checkpoint/undo before risky transforms.",
37602
- args: {
37603
- op: z16.enum(["add_member", "add_derive", "wrap_try_catch", "add_decorator", "add_struct_tags"]).describe("Transformation operation"),
37604
- filePath: z16.string().describe("Path to the source file (absolute or relative to project root)"),
37605
- container: z16.string().optional().describe("Container name for add_member — the class, struct, or impl block to insert into. Appears as 'scope' in the response."),
37606
- code: z16.string().optional().describe("Member code to insert (add_member)"),
37607
- position: z16.string().optional().describe("For add_member: 'first', 'last' (default), 'before:name', 'after:name'. For add_decorator: 'first' (default) or 'last' only."),
37608
- target: z16.string().optional().describe("Target symbol name (add_derive: struct/enum, wrap_try_catch: function, add_decorator: function/class, add_struct_tags: struct)"),
37609
- derives: z16.array(z16.string()).optional().describe("Derive macro names (add_derive — e.g. ['Clone', 'Debug'])"),
37610
- catchBody: z16.string().optional().describe("Catch block body (wrap_try_catch — default: 'throw error;')"),
37611
- decorator: z16.string().optional().describe("Decorator text without @ (add_decorator — e.g. 'staticmethod')"),
37612
- field: z16.string().optional().describe("Struct field name (add_struct_tags)"),
37613
- tag: z16.string().optional().describe("Tag key (add_struct_tags — e.g. 'json')"),
37614
- value: z16.string().optional().describe("Tag value (add_struct_tags — e.g. 'user_name,omitempty')"),
37615
- validate: z16.enum(["syntax", "full"]).optional().describe("Validation level: 'syntax' (default) or 'full'. Syntax = tree-sitter parse check only. Full = also runs LSP type-checking (slower, catches more errors)")
37616
- },
37617
- execute: async (args, context) => {
37618
- const op = args.op;
37619
- if (op === "add_member") {
37620
- if (isEmptyParam(args.container))
37621
- throw new Error("'container' is required for 'add_member' op");
37622
- if (isEmptyParam(args.code))
37623
- throw new Error("'code' is required for 'add_member' op");
37624
- }
37625
- if (op === "add_derive" || op === "wrap_try_catch" || op === "add_decorator" || op === "add_struct_tags") {
37626
- if (isEmptyParam(args.target))
37627
- throw new Error(`'target' is required for '${op}' op`);
37628
- }
37629
- if (op === "add_derive" && isEmptyParam(args.derives)) {
37630
- throw new Error("'derives' array is required for 'add_derive' op");
37631
- }
37632
- if (op === "add_decorator" && isEmptyParam(args.decorator)) {
37633
- throw new Error("'decorator' is required for 'add_decorator' op");
37634
- }
37635
- if (op === "add_struct_tags") {
37636
- if (isEmptyParam(args.field))
37637
- throw new Error("'field' is required for 'add_struct_tags' op");
37638
- if (isEmptyParam(args.tag))
37639
- throw new Error("'tag' is required for 'add_struct_tags' op");
37640
- if (isEmptyParam(args.value))
37641
- throw new Error("'value' is required for 'add_struct_tags' op");
37642
- }
37643
- const filePath = await resolvePathArg(ctx, context, args.filePath);
37644
- {
37645
- const denial = await assertExternalDirectoryPermission(context, filePath);
37646
- if (denial)
37647
- return permissionDeniedResponse(denial);
37648
- }
37649
- const permissionError = await askEditPermission(context, [resolveRelativePattern(context, filePath)], { filepath: filePath });
37650
- if (permissionError)
37651
- return permissionDeniedResponse(permissionError);
37652
- const params = { file: filePath };
37653
- if (args.validate !== undefined)
37654
- params.validate = args.validate;
37655
- switch (op) {
37656
- case "add_member":
37657
- params.scope = args.container;
37658
- params.code = args.code;
37659
- if (args.position !== undefined)
37660
- params.position = args.position;
37661
- break;
37662
- case "add_derive":
37663
- params.target = args.target;
37664
- params.derives = args.derives;
37665
- break;
37666
- case "wrap_try_catch":
37667
- params.target = args.target;
37668
- if (args.catchBody !== undefined)
37669
- params.catch_body = args.catchBody;
37670
- break;
37671
- case "add_decorator":
37672
- params.target = args.target;
37673
- params.decorator = args.decorator;
37674
- if (args.position !== undefined)
37675
- params.position = args.position;
37676
- break;
37677
- case "add_struct_tags":
37678
- params.target = args.target;
37679
- params.field = args.field;
37680
- params.tag = args.tag;
37681
- params.value = args.value;
37682
- break;
37683
- }
37684
- const response = await callBridge(ctx, context, op, params);
37685
- if (response.success === false) {
37686
- throw new Error(response.message || `${op} failed`);
37687
- }
37688
- return JSON.stringify(response);
37689
- }
37690
- }
37691
- };
37692
- }
37693
-
37694
37961
  // src/workflow-hints.ts
37695
- var HEADING = "## Prefer AFT tools for token efficiency";
37962
+ var HEADING = "## IMPORTANT NOTICE about your tools";
37696
37963
  function buildWorkflowHints(opts) {
37697
37964
  const sections = [];
37698
37965
  const grepName = opts.hoistBuiltins ? "grep" : "aft_grep";
@@ -37708,14 +37975,28 @@ function buildWorkflowHints(opts) {
37708
37975
  const hasBash = !opts.disabledTools.has(bashName);
37709
37976
  const hasBgBash = hasBash && opts.bashBackgroundEnabled && !opts.disabledTools.has(bashStatusName);
37710
37977
  if (hasBash && opts.bashCompressionEnabled) {
37711
- sections.push("When AFT bash output compression is on, do NOT pipe test/build commands through grep/head/tail (e.g. `bun test | grep fail`) to summarize. The compressor already keeps failures and the summary; piping hides failures. Run the command bare.");
37978
+ sections.push([
37979
+ "**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:",
37980
+ "- `bun test | grep fail` → run `bun test`",
37981
+ "- `cargo test 2>&1 | tail -20` → run `cargo test`",
37982
+ "- `npm run build | head -50` → run `npm run build`"
37983
+ ].join(`
37984
+ `));
37712
37985
  }
37713
37986
  if (hasOutline && hasZoom) {
37714
37987
  sections.push(`**Web/URL access**: \`aft_outline({ target: url })\` first for structure, then \`aft_zoom({ url, symbols: "<heading>" })\` for the specific section.`);
37715
37988
  }
37716
37989
  if (hasOutline && hasZoom && (hasGrep || hasSearch)) {
37990
+ const searchName = hasSearch ? "aft_search" : grepName;
37717
37991
  const locate = hasSearch ? '`aft_search` is the primary code-search tool: one call auto-routes concepts, identifiers, regex, error strings, and literals (pass `hint: "regex"`/`"literal"`/`"semantic"` to force a lane).' : `\`${grepName}\` (the tool — indexed and ranked) locates code.`;
37718
- sections.push(`**Code exploration**: fire independent lookups in ONE parallel tool-call wave — do NOT serialize them. ${locate} Then \`aft_outline\` for structure → \`aft_zoom\` for symbol(s). DO NOT run \`grep\`/\`rg\`/\`find\` through \`bash\` to locate code — the bash path is unindexed, unranked, serial, and routinely surfaces the wrong hit. Keep \`bash\` for shell facts (git state, file metadata, processes).`);
37992
+ const readName = opts.hoistBuiltins ? "read" : "aft_read";
37993
+ sections.push([
37994
+ `**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:`,
37995
+ `- \`grep -rn "handleAuth" src/\` in bash → \`${searchName}({ query: "handleAuth" })\``,
37996
+ `- \`find . -name "*.ts" | xargs grep watcher\` in bash → \`${searchName}({ query: "watcher invalidation" })\` (concepts work too)`,
37997
+ `- \`sed -n '100,160p' app.ts\` / \`cat app.ts\` in bash → \`${readName}({ filePath: "app.ts", startLine: 100, endLine: 160 })\``
37998
+ ].join(`
37999
+ `));
37719
38000
  }
37720
38001
  if (hasInspect) {
37721
38002
  sections.push("**Codebase health & diagnostics**: AFT does not surface compile/type errors automatically after edits — pull them with `aft_inspect`. Run it after a batch of edits and before you run tests or commit, when starting in unfamiliar code, or before a refactor/review. One call summarizes diagnostics (compile/type errors), TODOs, metrics, dead code, unused exports, and duplicates; pass `sections` for focused drill-down and `scope` to actively pull diagnostics for a specific file or directory. Its diagnostics are a fast checkpoint, not the authority — a clean `tsc` / `cargo check` / `pyright` run is the real gate. Treat `stale_categories` as a genuine stale-cache signal while an async Tier 2 refresh catches up.");
@@ -37733,13 +38014,22 @@ function buildWorkflowHints(opts) {
37733
38014
  `));
37734
38015
  }
37735
38016
  if (hasBash && hasBgBash) {
37736
- sections.push(`**Long-running commands** (builds, installs, full test suites): \`${bashName}({ background: true })\` returns immediately with a \`taskId\`, then **end your turn** — a completion reminder arrives automatically when it finishes. Do not poll \`${bashStatusName}({ taskId })\`, and do not sync-wait with \`bash_watch\` for a long task: blocking freezes your turn and locks the user out until it ends. For an early non-blocking ping on a specific output line, register an async watch \`bash_watch({ taskId, pattern, background: true })\`. \`bash_watch\` synchronous mode is only for short bounded waits (seconds, e.g. a dev server printing a readiness line), never for multi-minute jobs.`);
38017
+ sections.push([
38018
+ `**Long-running commands** (builds, installs, full test suites): \`${bashName}({ background: true })\` returns a \`taskId\` immediately. Then do ONE of these:`,
38019
+ "1. Keep working on something independent of the result.",
38020
+ "2. End your turn — a completion reminder with the result arrives automatically.",
38021
+ `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.`,
38022
+ `\`${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.`
38023
+ ].join(`
38024
+ `));
37737
38025
  sections.push(`**PTY / interactive commands**: PTY mode is for interactive REPLs and terminal apps (python, node, bash itself, vim). Start with \`${bashName}({ command: "python", pty: true, background: true })\`, read the screen with \`${bashStatusName}({ taskId, outputMode: "screen" })\`, and send input with \`${bashWriteName}({ taskId, input: "..." })\`.`);
37738
38026
  }
37739
38027
  if (sections.length === 0) {
37740
38028
  return null;
37741
38029
  }
37742
- sections.unshift("**Parallel tool calls**: when several read-only operations are independent, emit them in ONE response instead of serializing file reads, structure and symbol lookups, code search, diagnostics, and git status/diff/log. Sequence only when a call depends on a prior result or when a command mutates state.");
38030
+ 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.
38031
+
38032
+ **Parallel tool calls**: when several read-only operations are independent, emit them in ONE response instead of serializing — file reads, structure and symbol lookups, code search, diagnostics, and git status/diff/log. Sequence only when a call depends on a prior result or when a command mutates state.`);
37743
38033
  return `${HEADING}
37744
38034
 
37745
38035
  ${sections.join(`
@@ -37801,12 +38091,12 @@ var PLUGIN_VERSION = (() => {
37801
38091
  return "0.0.0";
37802
38092
  }
37803
38093
  })();
37804
- var ANNOUNCEMENT_VERSION = "0.36.0";
38094
+ var ANNOUNCEMENT_VERSION = "0.37.0";
37805
38095
  var ANNOUNCEMENT_FEATURES = [
37806
- "Persisted call graph: dead-code analysis runs on large repositories again (the file-count cap is gone), `aft_callgraph` queries resolve from disk, and method-call edges are more accurate across more languages.",
37807
- "Bash output stops hiding failures: piping a test/build through `grep`/`head` with compression on now keeps the failures and summary instead of stripping them.",
37808
- "Code search steering: guidance and hints now point to `aft_search` and parallel `aft_*` tools instead of `grep`/`find` in bash.",
37809
- "Fixes: a small `timeout` no longer kills a foreground `bash` command (#102), and `aft_delete`/`aft_safety checkpoint` no longer crash on a mistyped `files` argument."
38096
+ "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.",
38097
+ "`aft_transform` removed: usage data showed agents never called it `edit` covers everything it did, and every request now carries a smaller tool surface.",
38098
+ "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.",
38099
+ "Background bash reminders are right-sized: failures keep head + tail context, successes show a short tail, and compressors never print a success summary for a non-zero exit."
37810
38100
  ];
37811
38101
  var ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/DSa65w8wuf";
37812
38102
  var plugin = async (input) => initializePluginForDirectory(input);
@@ -38031,17 +38321,25 @@ ${lines}
38031
38321
  });
38032
38322
  rpcServer.handle("status", async (params) => {
38033
38323
  const sessionID = params.sessionID || "rpc";
38034
- const cachedDir = getSessionDirectoryCached(sessionID);
38035
- const candidateDirs = new Set;
38036
- candidateDirs.add(input.directory);
38037
- if (typeof cachedDir === "string" && cachedDir.length > 0) {
38038
- candidateDirs.add(cachedDir);
38039
- }
38040
38324
  let bridge = null;
38041
- for (const dir of candidateDirs) {
38042
- bridge = pool.getActiveBridgeForRoot(dir);
38043
- if (bridge)
38044
- break;
38325
+ let servedDirectory = input.directory;
38326
+ const realSessionID = params.sessionID || "";
38327
+ const verifiedDir = realSessionID ? await verifySessionDirectory(input.client, realSessionID) : null;
38328
+ if (verifiedDir) {
38329
+ bridge = pool.getActiveBridgeForRoot(verifiedDir);
38330
+ if (bridge) {
38331
+ servedDirectory = verifiedDir;
38332
+ } else if (verifiedDir !== input.directory) {
38333
+ return {
38334
+ success: true,
38335
+ status: "not_initialized",
38336
+ message: "AFT bridge is now spawned lazily, information here will be populated after first tool call."
38337
+ };
38338
+ }
38339
+ }
38340
+ if (!bridge) {
38341
+ bridge = pool.getActiveBridgeForRoot(input.directory);
38342
+ servedDirectory = input.directory;
38045
38343
  }
38046
38344
  if (!bridge) {
38047
38345
  return {
@@ -38054,13 +38352,13 @@ ${lines}
38054
38352
  const cachedSessionId = cached2?.session;
38055
38353
  const cachedId = cachedSessionId?.id;
38056
38354
  if (cached2 !== null && cachedId === sessionID) {
38057
- return { success: true, ...cached2 };
38355
+ return { success: true, ...cached2, served_directory: servedDirectory };
38058
38356
  }
38059
38357
  const response = await bridge.send("status", { session_id: sessionID });
38060
38358
  if (response.success !== false) {
38061
38359
  bridge.cacheStatusSnapshot(response);
38062
38360
  }
38063
- return response;
38361
+ return { ...response, served_directory: servedDirectory };
38064
38362
  });
38065
38363
  const storageDir = configOverrides.storage_dir;
38066
38364
  rpcServer.handle("get-announcement", async () => {
@@ -38120,19 +38418,12 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38120
38418
  cleanupWarnings(notifyOpts).catch(() => {});
38121
38419
  }
38122
38420
  const surface = aftConfig.tool_surface ?? "recommended";
38123
- const ALL_ONLY_TOOLS = new Set([
38124
- "aft_callgraph",
38125
- "aft_delete",
38126
- "aft_move",
38127
- "aft_transform",
38128
- "aft_refactor"
38129
- ]);
38421
+ const ALL_ONLY_TOOLS = new Set(["aft_callgraph", "aft_delete", "aft_move", "aft_refactor"]);
38130
38422
  const allTools = normalizeToolMap({
38131
38423
  ...surface !== "minimal" && (aftConfig.hoist_builtin_tools !== false ? hoistedTools(ctx) : aftPrefixedTools(ctx)),
38132
38424
  ...readingTools(ctx),
38133
38425
  ...safetyTools(ctx),
38134
38426
  ...surface !== "minimal" && importTools(ctx),
38135
- ...structureTools(ctx),
38136
38427
  ...navigationTools(ctx),
38137
38428
  ...surface !== "minimal" && astTools(ctx),
38138
38429
  ...surface !== "minimal" && aftConfig.semantic_search === true && semanticTools(ctx),
@@ -38179,7 +38470,16 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38179
38470
  "bash_status"
38180
38471
  ];
38181
38472
  const registeredTools = new Set(Object.keys(allTools));
38182
- pool.setConfigureOverride("aft_search_registered", registeredTools.has("aft_search"));
38473
+ const aftSearchRegistered = registeredTools.has("aft_search");
38474
+ pool.setConfigureOverride("aft_search_registered", aftSearchRegistered);
38475
+ ctx.aftSearchRegistered = aftSearchRegistered;
38476
+ for (const name of ["bash", "aft_bash"]) {
38477
+ const def = allTools[name];
38478
+ if (def) {
38479
+ const bashCfg = resolveBashConfig(aftConfig);
38480
+ def.description = bashToolDescription(aftSearchRegistered, bashCfg.compress, bashCfg.background);
38481
+ }
38482
+ }
38183
38483
  const hintsAbsentTools = new Set;
38184
38484
  for (const name of HINTS_TOOL_NAMES) {
38185
38485
  if (!registeredTools.has(name))
@@ -38237,6 +38537,7 @@ Install: ${getManualInstallHint()}`).catch(() => {});
38237
38537
  "chat.message": async (messageInput) => {
38238
38538
  const sid = messageInput.sessionID ?? messageInput.sessionId ?? messageInput.id;
38239
38539
  warmSessionDirectory(input.client, sid, input.directory);
38540
+ signalSyncWatchAbort(sid);
38240
38541
  },
38241
38542
  "tool.execute.before": async (toolInput) => {
38242
38543
  if (toolInput.sessionID)