indusagi 0.12.34 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/agent.js +1247 -184
  2. package/dist/ai.js +72 -4
  3. package/dist/capabilities.js +69 -2
  4. package/dist/cli.js +83 -13
  5. package/dist/connectors-saas.js +66 -0
  6. package/dist/index.js +83 -13
  7. package/dist/interop.js +66 -0
  8. package/dist/mcp.js +270 -363
  9. package/dist/react-ink.js +15 -11
  10. package/dist/shell-app.js +83 -13
  11. package/dist/smithy.js +69 -2
  12. package/dist/swarm.js +69 -2
  13. package/dist/types/capabilities/backends/node-backends.d.ts +3 -1
  14. package/dist/types/capabilities/files/read-state-gate.d.ts +69 -0
  15. package/dist/types/capabilities/files/read-state-gate.test.d.ts +14 -0
  16. package/dist/types/capabilities/kernel/context.d.ts +4 -0
  17. package/dist/types/capabilities/kernel/index.d.ts +2 -2
  18. package/dist/types/capabilities/kernel/spec.d.ts +55 -0
  19. package/dist/types/facade/bot/actions/bash.d.ts +15 -0
  20. package/dist/types/facade/bot/actions/bash.test.d.ts +1 -0
  21. package/dist/types/facade/bot/actions/checkpoint.d.ts +49 -0
  22. package/dist/types/facade/bot/actions/checkpoint.test.d.ts +1 -0
  23. package/dist/types/facade/bot/actions/edit-utils.d.ts +86 -0
  24. package/dist/types/facade/bot/actions/edit.d.ts +18 -0
  25. package/dist/types/facade/bot/actions/edit.test.d.ts +1 -0
  26. package/dist/types/facade/bot/actions/find.d.ts +2 -0
  27. package/dist/types/facade/bot/actions/find.test.d.ts +1 -0
  28. package/dist/types/facade/bot/actions/grep.d.ts +10 -0
  29. package/dist/types/facade/bot/actions/grep.test.d.ts +1 -0
  30. package/dist/types/facade/bot/actions/index.d.ts +16 -0
  31. package/dist/types/facade/bot/actions/read-state.d.ts +83 -0
  32. package/dist/types/facade/bot/actions/read-state.test.d.ts +1 -0
  33. package/dist/types/facade/bot/actions/read.d.ts +7 -0
  34. package/dist/types/facade/bot/actions/read.test.d.ts +1 -0
  35. package/dist/types/facade/bot/actions/sandbox-backend.d.ts +99 -0
  36. package/dist/types/facade/bot/actions/sandbox-backend.test.d.ts +1 -0
  37. package/dist/types/facade/bot/actions/websearch.d.ts +5 -2
  38. package/dist/types/facade/bot/actions/websearch.test.d.ts +1 -0
  39. package/dist/types/facade/bot/actions/write.d.ts +15 -0
  40. package/dist/types/facade/bot/agent-loop.d.ts +10 -0
  41. package/dist/types/facade/bot/agent-loop.test.d.ts +1 -0
  42. package/dist/types/facade/bot/agent.d.ts +9 -1
  43. package/dist/types/facade/bot/permission-gate.test.d.ts +1 -0
  44. package/dist/types/facade/bot/types.d.ts +60 -0
  45. package/dist/types/facade/mcp-core/client.d.ts +71 -15
  46. package/dist/types/facade/mcp-core/client.test.d.ts +18 -0
  47. package/dist/types/facade/mcp-core/types.d.ts +10 -0
  48. package/dist/types/facade/ml/adapters/anthropic-retry.test.d.ts +1 -0
  49. package/dist/types/facade/ml/adapters/anthropic.d.ts +17 -0
  50. package/dist/types/facade/ml/adapters/simple-options.d.ts +13 -0
  51. package/dist/types/facade/ml/adapters/simple-options.test.d.ts +1 -0
  52. package/dist/types/react-ink/components/StatusLine.d.ts +10 -1
  53. package/dist/types/react-ink/components/ToolEventBlock.d.ts +2 -1
  54. package/dist/types/react-ink/components/ToolEventBlock.test.d.ts +1 -0
  55. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -13006,24 +13006,58 @@ function normalizeProviderError(error) {
13006
13006
  }
13007
13007
  return new SimpleOptionsProviderError("Unknown provider error", "unknown", error);
13008
13008
  }
13009
+ function abortReason(signal) {
13010
+ const reason = signal.reason;
13011
+ if (reason instanceof SimpleOptionsProviderError) return reason;
13012
+ if (reason instanceof Error) return new SimpleOptionsProviderError(reason.message, "unknown", reason);
13013
+ return new SimpleOptionsProviderError("Request was aborted", "unknown", reason);
13014
+ }
13015
+ function abortableSleep(ms, signal) {
13016
+ return new Promise((resolve5, reject) => {
13017
+ if (signal?.aborted) {
13018
+ reject(abortReason(signal));
13019
+ return;
13020
+ }
13021
+ let onAbort;
13022
+ const timer = setTimeout(() => {
13023
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
13024
+ resolve5();
13025
+ }, ms);
13026
+ if (signal) {
13027
+ onAbort = () => {
13028
+ clearTimeout(timer);
13029
+ signal.removeEventListener("abort", onAbort);
13030
+ reject(abortReason(signal));
13031
+ };
13032
+ signal.addEventListener("abort", onAbort, { once: true });
13033
+ }
13034
+ });
13035
+ }
13009
13036
  async function executeWithRetry(operation, policy) {
13010
13037
  let attempt = 0;
13011
13038
  let lastError;
13012
13039
  while (attempt < policy.maxAttempts) {
13040
+ if (policy.signal?.aborted) {
13041
+ throw abortReason(policy.signal);
13042
+ }
13013
13043
  attempt++;
13014
13044
  try {
13015
13045
  return await operation();
13016
13046
  } catch (error) {
13017
13047
  lastError = error;
13018
13048
  const normalized = normalizeProviderError(error);
13049
+ if (policy.signal?.aborted) {
13050
+ throw normalized;
13051
+ }
13019
13052
  const defaultRetryable = normalized.code === "rate_limit" || normalized.code === "timeout" || normalized.code === "network";
13020
13053
  const retryable = policy.shouldRetry ? policy.shouldRetry(error, attempt) : defaultRetryable;
13021
13054
  if (!retryable || attempt >= policy.maxAttempts) {
13022
13055
  throw normalized;
13023
13056
  }
13024
13057
  const maxDelay = policy.maxDelayMs ?? Number.MAX_SAFE_INTEGER;
13025
- const backoff = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), maxDelay);
13026
- await new Promise((resolve5) => setTimeout(resolve5, backoff));
13058
+ const retryAfterMs = policy.getRetryAfterMs?.(error) ?? null;
13059
+ const backoff = retryAfterMs != null ? Math.min(retryAfterMs, maxDelay) : Math.min(policy.baseDelayMs * 2 ** (attempt - 1), maxDelay);
13060
+ await abortableSleep(backoff, policy.signal);
13027
13061
  }
13028
13062
  }
13029
13063
  throw normalizeProviderError(lastError);
@@ -13499,6 +13533,30 @@ var AnthropicEventReducer = class {
13499
13533
  calculateCost(this.model, this.output.usage);
13500
13534
  }
13501
13535
  };
13536
+ function parseAnthropicRetryAfterMs(error) {
13537
+ if (error instanceof Anthropic.APIError) {
13538
+ const header = error.headers?.get?.("retry-after");
13539
+ const seconds = header ? parseInt(header, 10) : Number.NaN;
13540
+ if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1e3;
13541
+ }
13542
+ return null;
13543
+ }
13544
+ function shouldRetryAnthropic(error, _attempt) {
13545
+ if (error instanceof Anthropic.APIError) {
13546
+ const status = error.status;
13547
+ if (status === 408 || status === 409 || status === 429 || status === 529) return true;
13548
+ if (typeof status === "number" && status >= 500) return true;
13549
+ if (typeof status === "number" && status >= 400 && status < 500) return false;
13550
+ const body = `${error.message} ${JSON.stringify(error.error ?? "")}`.toLowerCase();
13551
+ if (body.includes('"type":"overloaded_error"') || body.includes("overloaded")) return true;
13552
+ return false;
13553
+ }
13554
+ if (error instanceof Error) {
13555
+ const msg = error.message.toLowerCase();
13556
+ return msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("timeout") || msg.includes("timed out") || msg.includes("network") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("overloaded");
13557
+ }
13558
+ return false;
13559
+ }
13502
13560
  var streamAnthropic = (model, context, options) => {
13503
13561
  const stream = new AssistantMessageEventStream();
13504
13562
  const output = createAssistantMessageOutput(model);
@@ -13526,8 +13584,16 @@ var streamAnthropic = (model, context, options) => {
13526
13584
  });
13527
13585
  },
13528
13586
  {
13529
- maxAttempts: options?.signal ? 1 : 3,
13530
- baseDelayMs: 250
13587
+ // Transient retries are NO LONGER gated on signal presence:
13588
+ // the live path always carries a signal, so the old ternary
13589
+ // collapsed to a single attempt = zero retries on 429/529/5xx.
13590
+ // Abort still short-circuits immediately (see executeWithRetry).
13591
+ maxAttempts: 3,
13592
+ baseDelayMs: 500,
13593
+ maxDelayMs: 32e3,
13594
+ signal: options?.signal,
13595
+ shouldRetry: shouldRetryAnthropic,
13596
+ getRetryAfterMs: parseAnthropicRetryAfterMs
13531
13597
  }
13532
13598
  );
13533
13599
  if (options?.signal?.aborted) {
@@ -18407,6 +18473,63 @@ var AgentTelemetry = class {
18407
18473
  // src/facade/bot/agent-loop.ts
18408
18474
  var errorHandler = new AgentErrorHandler();
18409
18475
  var telemetry = new AgentTelemetry();
18476
+ var READ_ONLY_TOOL_NAMES = /* @__PURE__ */ new Set([
18477
+ "read",
18478
+ "ls",
18479
+ "grep",
18480
+ "find",
18481
+ "websearch",
18482
+ "webfetch",
18483
+ "todoread"
18484
+ ]);
18485
+ function maxToolConcurrency(override) {
18486
+ if (typeof override === "number" && Number.isFinite(override) && override >= 1) {
18487
+ return Math.floor(override);
18488
+ }
18489
+ const fromEnv = parseInt(
18490
+ process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || process.env.INDUS_MAX_TOOL_CONCURRENCY || "",
18491
+ 10
18492
+ );
18493
+ return Number.isFinite(fromEnv) && fromEnv >= 1 ? fromEnv : 10;
18494
+ }
18495
+ function isConcurrencySafe(toolCall, tools, readOnly) {
18496
+ if (!readOnly.has(toolCall.name)) return false;
18497
+ return Boolean(tools?.some((candidate) => candidate.name === toolCall.name));
18498
+ }
18499
+ function partitionToolCalls(calls, tools, readOnly) {
18500
+ const batches = [];
18501
+ for (const call of calls) {
18502
+ const concurrent = isConcurrencySafe(call, tools, readOnly);
18503
+ const trailing = batches[batches.length - 1];
18504
+ if (concurrent && trailing && trailing.concurrent) {
18505
+ trailing.calls.push(call);
18506
+ } else {
18507
+ batches.push({ concurrent, calls: [call] });
18508
+ }
18509
+ }
18510
+ return batches;
18511
+ }
18512
+ async function runToolBatchConcurrently(calls, tools, signal, stream, limit, canUseTool) {
18513
+ const results = new Array(calls.length);
18514
+ let next = 0;
18515
+ const poolSize = Math.max(1, Math.min(limit, calls.length));
18516
+ const worker = async () => {
18517
+ for (; ; ) {
18518
+ const index = next++;
18519
+ if (index >= calls.length) return;
18520
+ const toolCall = calls[index];
18521
+ const key = `tool:${toolCall.name}:${toolCall.id}`;
18522
+ telemetry.start(key);
18523
+ results[index] = await executeToolCall(toolCall, tools, signal, stream, {
18524
+ emitMessageEvents: true,
18525
+ canUseTool
18526
+ });
18527
+ telemetry.log(`tool:${toolCall.name}`, telemetry.end(key));
18528
+ }
18529
+ };
18530
+ await Promise.all(Array.from({ length: poolSize }, () => worker()));
18531
+ return results;
18532
+ }
18410
18533
  function agentLoop(prompts, context, config, signal, streamFn) {
18411
18534
  const stream = createAgentStream();
18412
18535
  void (async () => {
@@ -18496,7 +18619,12 @@ async function runLoop(liveContext, produced, config, signal, stream, streamFn)
18496
18619
  assistantMessage,
18497
18620
  signal,
18498
18621
  stream,
18499
- config.getSteeringMessages
18622
+ config.getSteeringMessages,
18623
+ {
18624
+ readOnlyToolNames: config.readOnlyToolNames ?? READ_ONLY_TOOL_NAMES,
18625
+ maxConcurrency: maxToolConcurrency(config.maxToolConcurrency),
18626
+ canUseTool: config.canUseTool
18627
+ }
18500
18628
  );
18501
18629
  for (const result of dispatch.toolResults) {
18502
18630
  toolResults.push(result);
@@ -18581,7 +18709,40 @@ async function executeToolCall(toolCall, tools, signal, stream, options) {
18581
18709
  let isError = false;
18582
18710
  try {
18583
18711
  if (!matchedTool) throw new Error(`No registered tool named "${toolCall.name}".`);
18584
- const validatedArgs = validateToolArguments(matchedTool, toolCall);
18712
+ let validatedArgs = validateToolArguments(matchedTool, toolCall);
18713
+ if (options.canUseTool) {
18714
+ const decision = await options.canUseTool(toolCall.name, validatedArgs, { signal });
18715
+ if (decision.behavior === "deny") {
18716
+ const denied = {
18717
+ content: [{ type: "text", text: decision.message }],
18718
+ details: {},
18719
+ isError: true
18720
+ };
18721
+ stream.push({
18722
+ type: "tool_execution_end",
18723
+ toolCallId: toolCall.id,
18724
+ toolName: toolCall.name,
18725
+ result: denied,
18726
+ isError: true
18727
+ });
18728
+ const deniedMessage = {
18729
+ role: "toolResult",
18730
+ toolCallId: toolCall.id,
18731
+ toolName: toolCall.name,
18732
+ content: denied.content,
18733
+ details: denied.details,
18734
+ isError: true,
18735
+ timestamp: Date.now()
18736
+ };
18737
+ if (options.emitMessageEvents) {
18738
+ emitMessageStartEnd(stream, deniedMessage);
18739
+ }
18740
+ return deniedMessage;
18741
+ }
18742
+ if (decision.updatedInput !== void 0) {
18743
+ validatedArgs = decision.updatedInput;
18744
+ }
18745
+ }
18585
18746
  result = await matchedTool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
18586
18747
  stream.push({
18587
18748
  type: "tool_execution_update",
@@ -18621,16 +18782,30 @@ async function executeToolCall(toolCall, tools, signal, stream, options) {
18621
18782
  }
18622
18783
  return toolResultMessage;
18623
18784
  }
18624
- async function executeToolCalls(tools, assistantMessage, signal, stream, getSteeringMessages) {
18785
+ async function executeToolCalls(tools, assistantMessage, signal, stream, getSteeringMessages, options) {
18625
18786
  const requestedCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
18787
+ const readOnly = options?.readOnlyToolNames ?? READ_ONLY_TOOL_NAMES;
18788
+ const limit = options?.maxConcurrency ?? maxToolConcurrency();
18789
+ const canUseTool = options?.canUseTool;
18626
18790
  const results = [];
18627
18791
  let steeringMessages;
18628
- for (let index = 0; index < requestedCalls.length; index++) {
18629
- const toolCall = requestedCalls[index];
18630
- telemetry.start(`tool:${toolCall.name}`);
18631
- const toolResult = await executeToolCall(toolCall, tools, signal, stream, { emitMessageEvents: true });
18632
- telemetry.log(`tool:${toolCall.name}`, telemetry.end(`tool:${toolCall.name}`));
18633
- results.push(toolResult);
18792
+ const batches = partitionToolCalls(requestedCalls, tools, readOnly);
18793
+ let dispatched = 0;
18794
+ for (const batch of batches) {
18795
+ if (batch.concurrent && batch.calls.length > 1) {
18796
+ const batchResults = await runToolBatchConcurrently(batch.calls, tools, signal, stream, limit, canUseTool);
18797
+ results.push(...batchResults);
18798
+ } else {
18799
+ const toolCall = batch.calls[0];
18800
+ telemetry.start(`tool:${toolCall.name}`);
18801
+ const toolResult = await executeToolCall(toolCall, tools, signal, stream, {
18802
+ emitMessageEvents: true,
18803
+ canUseTool
18804
+ });
18805
+ telemetry.log(`tool:${toolCall.name}`, telemetry.end(`tool:${toolCall.name}`));
18806
+ results.push(toolResult);
18807
+ }
18808
+ dispatched += batch.calls.length;
18634
18809
  if (!getSteeringMessages) {
18635
18810
  continue;
18636
18811
  }
@@ -18639,7 +18814,7 @@ async function executeToolCalls(tools, assistantMessage, signal, stream, getStee
18639
18814
  continue;
18640
18815
  }
18641
18816
  steeringMessages = steering;
18642
- for (const skipped of requestedCalls.slice(index + 1)) {
18817
+ for (const skipped of requestedCalls.slice(dispatched)) {
18643
18818
  results.push(skipToolCall(skipped, stream));
18644
18819
  }
18645
18820
  break;
@@ -18868,6 +19043,7 @@ var LoopConfigFactory = class {
18868
19043
  convertToLlm: args.convertToLlm,
18869
19044
  transformContext: args.transformContext,
18870
19045
  getApiKey: args.getApiKey,
19046
+ canUseTool: args.canUseTool,
18871
19047
  getSteeringMessages: args.getSteeringMessages,
18872
19048
  getFollowUpMessages: args.getFollowUpMessages
18873
19049
  };
@@ -18940,6 +19116,7 @@ var Agent = class {
18940
19116
  streamFn;
18941
19117
  _sessionId;
18942
19118
  getApiKey;
19119
+ canUseTool;
18943
19120
  runningPrompt;
18944
19121
  resolveRunningPrompt;
18945
19122
  _thinkingBudgets;
@@ -18950,6 +19127,7 @@ var Agent = class {
18950
19127
  this.streamFn = opts.streamFn || streamSimple;
18951
19128
  this._sessionId = opts.sessionId;
18952
19129
  this.getApiKey = opts.getApiKey;
19130
+ this.canUseTool = opts.canUseTool;
18953
19131
  this._thinkingBudgets = opts.thinkingBudgets;
18954
19132
  this.queuePolicy = new MessageQueuePolicy(opts.steeringMode || "one-at-a-time", opts.followUpMode || "one-at-a-time");
18955
19133
  }
@@ -19109,6 +19287,7 @@ var Agent = class {
19109
19287
  convertToLlm: this.convertToLlm,
19110
19288
  transformContext: this.transformContext,
19111
19289
  getApiKey: this.getApiKey,
19290
+ canUseTool: this.canUseTool,
19112
19291
  getSteeringMessages: async () => this.queuePolicy.dequeueSteeringMessages(),
19113
19292
  getFollowUpMessages: async () => this.queuePolicy.dequeueFollowUpMessages()
19114
19293
  });
@@ -19421,7 +19600,7 @@ function isToolCall(value) {
19421
19600
  // src/facade/bot/actions/bash.ts
19422
19601
  import { randomBytes } from "node:crypto";
19423
19602
  import { createWriteStream, existsSync as existsSync2 } from "node:fs";
19424
- import { tmpdir } from "node:os";
19603
+ import { tmpdir as tmpdir2 } from "node:os";
19425
19604
  import { join } from "node:path";
19426
19605
  import { Type as Type2 } from "@sinclair/typebox";
19427
19606
  import { spawn as spawn2 } from "child_process";
@@ -19500,6 +19679,128 @@ function killProcessTree(pid) {
19500
19679
  }
19501
19680
  }
19502
19681
 
19682
+ // src/facade/bot/actions/sandbox-backend.ts
19683
+ import { spawnSync as spawnSync2 } from "node:child_process";
19684
+ import { tmpdir } from "node:os";
19685
+ function resolveWritableRoots(config, cwd) {
19686
+ const tmp = config.tmpDir ?? tmpdir();
19687
+ const extra = config.writableRoots ?? [];
19688
+ const seen = /* @__PURE__ */ new Set();
19689
+ const roots = [];
19690
+ for (const p of [cwd, tmp, ...extra]) {
19691
+ if (p && !seen.has(p)) {
19692
+ seen.add(p);
19693
+ roots.push(p);
19694
+ }
19695
+ }
19696
+ return roots;
19697
+ }
19698
+ function escapeSeatbeltPath(p) {
19699
+ return p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
19700
+ }
19701
+ function buildSeatbeltProfile(config, cwd) {
19702
+ const roots = resolveWritableRoots(config, cwd);
19703
+ const writeRules = roots.map((p) => `(allow file-write* (subpath "${escapeSeatbeltPath(p)}"))`).join("\n");
19704
+ const networkRule = config.allowNetwork ? "(allow network*)" : "(deny network* (with no-log))";
19705
+ return [
19706
+ "(version 1)",
19707
+ "(deny default)",
19708
+ // Allow the command and its children to run.
19709
+ "(allow process-exec)",
19710
+ "(allow process-fork)",
19711
+ "(allow signal (target same-sandbox))",
19712
+ "(allow sysctl-read)",
19713
+ // Read is permitted everywhere; the boundary we enforce is on writes.
19714
+ "(allow file-read*)",
19715
+ writeRules,
19716
+ networkRule
19717
+ ].filter((line) => line.length > 0).join("\n");
19718
+ }
19719
+ function buildSeatbeltArgv(config, cwd, command) {
19720
+ const profile = buildSeatbeltProfile(config, cwd);
19721
+ return ["sandbox-exec", "-p", profile, "/bin/sh", "-c", command];
19722
+ }
19723
+ function buildBwrapArgv(config, cwd, command) {
19724
+ const roots = resolveWritableRoots(config, cwd);
19725
+ const argv = ["bwrap", "--ro-bind", "/", "/"];
19726
+ for (const root of roots) {
19727
+ argv.push("--bind", root, root);
19728
+ }
19729
+ argv.push("--dev", "/dev", "--proc", "/proc");
19730
+ if (!config.allowNetwork) {
19731
+ argv.push("--unshare-net");
19732
+ }
19733
+ argv.push("--chdir", cwd, "/bin/sh", "-c", command);
19734
+ return argv;
19735
+ }
19736
+ function binaryOnPath(binary) {
19737
+ try {
19738
+ const result = spawnSync2("/bin/sh", ["-c", `command -v ${binary}`], {
19739
+ encoding: "utf8",
19740
+ timeout: 5e3
19741
+ });
19742
+ return result.status === 0 && typeof result.stdout === "string" && result.stdout.trim().length > 0;
19743
+ } catch {
19744
+ return false;
19745
+ }
19746
+ }
19747
+ function sandboxAvailability(platform = process.platform, probe = binaryOnPath) {
19748
+ if (platform === "darwin") {
19749
+ const binaryPresent = probe("sandbox-exec");
19750
+ return {
19751
+ platform: "macos",
19752
+ binary: "sandbox-exec",
19753
+ binaryPresent,
19754
+ reason: binaryPresent ? void 0 : "sandbox-exec not found on PATH"
19755
+ };
19756
+ }
19757
+ if (platform === "linux") {
19758
+ const binaryPresent = probe("bwrap");
19759
+ return {
19760
+ platform: "linux",
19761
+ binary: "bwrap",
19762
+ binaryPresent,
19763
+ reason: binaryPresent ? void 0 : "bwrap (bubblewrap) not found on PATH"
19764
+ };
19765
+ }
19766
+ return {
19767
+ platform: "unsupported",
19768
+ binary: null,
19769
+ binaryPresent: false,
19770
+ reason: `OS sandbox is not supported on platform "${platform}"`
19771
+ };
19772
+ }
19773
+ function buildSandboxArgv(config, cwd, command, availability = sandboxAvailability()) {
19774
+ if (!availability.binaryPresent) return null;
19775
+ if (availability.platform === "macos") return buildSeatbeltArgv(config, cwd, command);
19776
+ if (availability.platform === "linux") return buildBwrapArgv(config, cwd, command);
19777
+ return null;
19778
+ }
19779
+ function shellQuote(token) {
19780
+ return `'${token.replace(/'/g, "'\\''")}'`;
19781
+ }
19782
+ function argvToCommandString(argv) {
19783
+ return argv.map(shellQuote).join(" ");
19784
+ }
19785
+ function createSandboxedBashOperations(config, options) {
19786
+ if (!config.enabled) return options.base;
19787
+ const availability = options.availability ?? sandboxAvailability();
19788
+ return {
19789
+ exec: (command, cwd, execOptions) => {
19790
+ const argv = buildSandboxArgv(config, cwd, command, availability);
19791
+ if (!argv) {
19792
+ options.onNote?.(
19793
+ `sandbox NOT applied (${availability.reason ?? "unavailable"}); ran un-sandboxed`
19794
+ );
19795
+ return options.base.exec(command, cwd, execOptions);
19796
+ }
19797
+ options.onNote?.(`sandbox applied via ${availability.binary}`);
19798
+ const wrapped = argvToCommandString(argv);
19799
+ return options.base.exec(wrapped, cwd, execOptions);
19800
+ }
19801
+ };
19802
+ }
19803
+
19503
19804
  // src/facade/bot/actions/truncate.ts
19504
19805
  var DEFAULT_MAX_LINES = 2500;
19505
19806
  var DEFAULT_MAX_BYTES = 64 * 1024;
@@ -19687,7 +19988,7 @@ function truncateLine(line, maxChars = GREP_MAX_LINE_LENGTH) {
19687
19988
  // src/facade/bot/actions/bash.ts
19688
19989
  function allocateSpillFilePath() {
19689
19990
  const suffix = randomBytes(8).toString("hex");
19690
- return join(tmpdir(), `indusvx-bash-${suffix}.log`);
19991
+ return join(tmpdir2(), `indusvx-bash-${suffix}.log`);
19691
19992
  }
19692
19993
  var BashCommandBuilder = class {
19693
19994
  constructor(prefix) {
@@ -19864,9 +20165,15 @@ ${note}` : note);
19864
20165
  }
19865
20166
  };
19866
20167
  var DEFAULT_BLOCKED_PATTERNS = [/\brm\s+-rf\s+\/$/, /:\(\)\s*\{\s*:\|:\s*&\s*\};:/];
20168
+ var DEFAULT_BASH_TIMEOUT_SECONDS = 120;
20169
+ var MAX_BASH_TIMEOUT_SECONDS = 600;
20170
+ function computeEffectiveTimeout(requested, def, max) {
20171
+ const base = requested !== void 0 && requested > 0 ? requested : def;
20172
+ return Math.min(base, max);
20173
+ }
19867
20174
  var bashSchema = Type2.Object({
19868
20175
  command: Type2.String({ description: "The bash command line to execute" }),
19869
- timeout: Type2.Optional(Type2.Number({ description: "Optional limit, in seconds, after which the command is terminated. No limit applies when omitted." }))
20176
+ timeout: Type2.Optional(Type2.Number({ description: `Optional limit, in seconds, after which the command is terminated (default ${DEFAULT_BASH_TIMEOUT_SECONDS}s, max ${MAX_BASH_TIMEOUT_SECONDS}s).` }))
19870
20177
  });
19871
20178
  var defaultBashOperations = {
19872
20179
  exec: (command, cwd, { onData, signal, timeout, env }) => {
@@ -19932,21 +20239,28 @@ var defaultBashOperations = {
19932
20239
  }
19933
20240
  };
19934
20241
  function createBashTool(cwd, options) {
19935
- const ops = options?.operations ?? defaultBashOperations;
20242
+ const baseOps = options?.operations ?? defaultBashOperations;
20243
+ const ops = options?.sandbox?.enabled ? createSandboxedBashOperations(options.sandbox, {
20244
+ base: baseOps,
20245
+ onNote: options.onSandboxNote
20246
+ }) : baseOps;
19936
20247
  const commandBuilder = new BashCommandBuilder(options?.commandPrefix);
19937
20248
  const securityPolicy = new BashSecurityPolicy(
19938
20249
  options?.security?.blockedPatterns ?? DEFAULT_BLOCKED_PATTERNS,
19939
20250
  options?.commandHistory
19940
20251
  );
19941
20252
  const hookRunner = options?.hookRunner;
20253
+ const defaultTimeoutSeconds = options?.defaultTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS;
20254
+ const maxTimeoutSeconds = Math.max(options?.maxTimeoutSeconds ?? MAX_BASH_TIMEOUT_SECONDS, defaultTimeoutSeconds);
19942
20255
  return {
19943
20256
  name: "bash",
19944
20257
  label: "bash",
19945
- description: `Execute a bash command in the current working directory and return its merged stdout and stderr. At most the final ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB are returned, whichever limit is hit first; once that occurs the complete output is saved to a temp file. An optional timeout in seconds may be supplied.`,
20258
+ description: `Execute a bash command in the current working directory and return its merged stdout and stderr. At most the final ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB are returned, whichever limit is hit first; once that occurs the complete output is saved to a temp file. An optional timeout in seconds may be supplied. Commands time out after ${defaultTimeoutSeconds}s by default (max ${maxTimeoutSeconds}s).`,
19946
20259
  parameters: bashSchema,
19947
20260
  execute: async (_toolCallId, { command, timeout }, signal, onUpdate) => {
19948
20261
  securityPolicy.assertAllowed(command);
19949
20262
  securityPolicy.record(command);
20263
+ const effectiveTimeout = computeEffectiveTimeout(timeout, defaultTimeoutSeconds, maxTimeoutSeconds);
19950
20264
  const resolvedCommand = commandBuilder.build(command);
19951
20265
  const hookEnv = hookRunner?.hasHandlers("shell.env") ? (await hookRunner.trigger("shell.env", { cwd }, { env: {} })).env : void 0;
19952
20266
  const safeEnv = hookEnv ? Object.fromEntries(
@@ -19958,7 +20272,7 @@ function createBashTool(cwd, options) {
19958
20272
  ops.exec(resolvedCommand, cwd, {
19959
20273
  onData: (data) => outputCapture.appendChunk(data, emitPartial),
19960
20274
  signal,
19961
- timeout,
20275
+ timeout: effectiveTimeout,
19962
20276
  env: safeEnv
19963
20277
  }).then(({ exitCode }) => {
19964
20278
  outputCapture.close();
@@ -20291,6 +20605,216 @@ async function computeEditDiff(path9, oldText, newText, cwd) {
20291
20605
  return computation.compute(path9, oldText, newText);
20292
20606
  }
20293
20607
 
20608
+ // src/facade/bot/actions/edit-utils.ts
20609
+ import { readdirSync } from "node:fs";
20610
+ import { realpath, stat } from "node:fs/promises";
20611
+ import { basename, dirname, extname, join as join2, relative, sep } from "node:path";
20612
+ var LEFT_SINGLE_CURLY_QUOTE = "\u2018";
20613
+ var RIGHT_SINGLE_CURLY_QUOTE = "\u2019";
20614
+ var LEFT_DOUBLE_CURLY_QUOTE = "\u201C";
20615
+ var RIGHT_DOUBLE_CURLY_QUOTE = "\u201D";
20616
+ function isOpeningContext(chars, index) {
20617
+ if (index === 0) {
20618
+ return true;
20619
+ }
20620
+ const prev = chars[index - 1];
20621
+ return prev === " " || prev === " " || prev === "\n" || prev === "\r" || prev === "(" || prev === "[" || prev === "{" || prev === "\u2014" || // em dash
20622
+ prev === "\u2013";
20623
+ }
20624
+ function applyCurlyDoubleQuotes(str) {
20625
+ const chars = [...str];
20626
+ const result = [];
20627
+ for (let i = 0; i < chars.length; i++) {
20628
+ if (chars[i] === '"') {
20629
+ result.push(isOpeningContext(chars, i) ? LEFT_DOUBLE_CURLY_QUOTE : RIGHT_DOUBLE_CURLY_QUOTE);
20630
+ } else {
20631
+ result.push(chars[i]);
20632
+ }
20633
+ }
20634
+ return result.join("");
20635
+ }
20636
+ function applyCurlySingleQuotes(str) {
20637
+ const chars = [...str];
20638
+ const result = [];
20639
+ for (let i = 0; i < chars.length; i++) {
20640
+ if (chars[i] === "'") {
20641
+ const prev = i > 0 ? chars[i - 1] : void 0;
20642
+ const next = i < chars.length - 1 ? chars[i + 1] : void 0;
20643
+ const prevIsLetter = prev !== void 0 && new RegExp("\\p{L}", "u").test(prev);
20644
+ const nextIsLetter = next !== void 0 && new RegExp("\\p{L}", "u").test(next);
20645
+ if (prevIsLetter && nextIsLetter) {
20646
+ result.push(RIGHT_SINGLE_CURLY_QUOTE);
20647
+ } else {
20648
+ result.push(isOpeningContext(chars, i) ? LEFT_SINGLE_CURLY_QUOTE : RIGHT_SINGLE_CURLY_QUOTE);
20649
+ }
20650
+ } else {
20651
+ result.push(chars[i]);
20652
+ }
20653
+ }
20654
+ return result.join("");
20655
+ }
20656
+ function preserveQuoteStyle(oldString, actualOldString, newString) {
20657
+ if (oldString === actualOldString) {
20658
+ return newString;
20659
+ }
20660
+ const hasDoubleQuotes = actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) || actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
20661
+ const hasSingleQuotes = actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) || actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
20662
+ if (!hasDoubleQuotes && !hasSingleQuotes) {
20663
+ return newString;
20664
+ }
20665
+ let result = newString;
20666
+ if (hasDoubleQuotes) {
20667
+ result = applyCurlyDoubleQuotes(result);
20668
+ }
20669
+ if (hasSingleQuotes) {
20670
+ result = applyCurlySingleQuotes(result);
20671
+ }
20672
+ return result;
20673
+ }
20674
+ var DESANITIZATIONS = {
20675
+ "<fnr>": "<function_results>",
20676
+ "<n>": "<name>",
20677
+ "</n>": "</name>",
20678
+ "<o>": "<output>",
20679
+ "</o>": "</output>",
20680
+ "<e>": "<error>",
20681
+ "</e>": "</error>",
20682
+ "<s>": "<system>",
20683
+ "</s>": "</system>",
20684
+ "<r>": "<result>",
20685
+ "</r>": "</result>",
20686
+ "< META_START >": "<META_START>",
20687
+ "< META_END >": "<META_END>",
20688
+ "< EOT >": "<EOT>",
20689
+ "< META >": "<META>",
20690
+ "< SOS >": "<SOS>",
20691
+ "\n\nH:": "\n\nHuman:",
20692
+ "\n\nA:": "\n\nAssistant:"
20693
+ };
20694
+ function desanitizeMatchString(matchString) {
20695
+ let result = matchString;
20696
+ const appliedReplacements = [];
20697
+ for (const [from, to] of Object.entries(DESANITIZATIONS)) {
20698
+ const beforeReplace = result;
20699
+ result = result.split(from).join(to);
20700
+ if (beforeReplace !== result) {
20701
+ appliedReplacements.push({ from, to });
20702
+ }
20703
+ }
20704
+ return { result, appliedReplacements };
20705
+ }
20706
+ function findSimilarFile(filePath) {
20707
+ try {
20708
+ const dir = dirname(filePath);
20709
+ const fileBaseName = basename(filePath, extname(filePath));
20710
+ const files = readdirSync(dir, { withFileTypes: true });
20711
+ const similarFiles = files.filter(
20712
+ (file) => basename(file.name, extname(file.name)) === fileBaseName && join2(dir, file.name) !== filePath
20713
+ );
20714
+ const firstMatch = similarFiles[0];
20715
+ return firstMatch ? firstMatch.name : void 0;
20716
+ } catch {
20717
+ return void 0;
20718
+ }
20719
+ }
20720
+ var FILE_NOT_FOUND_CWD_NOTE = "Note: your current working directory is";
20721
+ async function suggestPathUnderCwd(requestedPath, cwd) {
20722
+ const cwdParent = dirname(cwd);
20723
+ let resolvedPath = requestedPath;
20724
+ try {
20725
+ const resolvedDir = await realpath(dirname(requestedPath));
20726
+ resolvedPath = join2(resolvedDir, basename(requestedPath));
20727
+ } catch {
20728
+ }
20729
+ const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep;
20730
+ if (!resolvedPath.startsWith(cwdParentPrefix) || resolvedPath.startsWith(cwd + sep) || resolvedPath === cwd) {
20731
+ return void 0;
20732
+ }
20733
+ const relFromParent = relative(cwdParent, resolvedPath);
20734
+ const correctedPath = join2(cwd, relFromParent);
20735
+ try {
20736
+ await stat(correctedPath);
20737
+ return correctedPath;
20738
+ } catch {
20739
+ return void 0;
20740
+ }
20741
+ }
20742
+ function replaceAllLiteral(haystack, needle, value) {
20743
+ if (needle.length === 0) return haystack;
20744
+ const pieces = [];
20745
+ let from = 0;
20746
+ for (; ; ) {
20747
+ const at = haystack.indexOf(needle, from);
20748
+ if (at === -1) {
20749
+ pieces.push(haystack.slice(from));
20750
+ break;
20751
+ }
20752
+ pieces.push(haystack.slice(from, at), value);
20753
+ from = at + needle.length;
20754
+ }
20755
+ return pieces.join("");
20756
+ }
20757
+
20758
+ // src/facade/bot/actions/checkpoint.ts
20759
+ import { readFileSync, statSync } from "node:fs";
20760
+ function recordCheckpoint(handle, absPath) {
20761
+ if (!handle) return;
20762
+ let previous;
20763
+ try {
20764
+ statSync(absPath);
20765
+ previous = readFileSync(absPath, "utf-8");
20766
+ } catch (error) {
20767
+ if (error?.code === "ENOENT") {
20768
+ previous = null;
20769
+ } else {
20770
+ return;
20771
+ }
20772
+ }
20773
+ try {
20774
+ handle.record(absPath, previous);
20775
+ } catch {
20776
+ }
20777
+ }
20778
+
20779
+ // src/facade/bot/actions/read-state.ts
20780
+ import { statSync as statSync2 } from "node:fs";
20781
+ var READ_BEFORE_EDIT_MESSAGE = "File has not been read yet. Read it first before writing to it.";
20782
+ var MODIFIED_SINCE_READ_MESSAGE = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
20783
+ function recordReadState(handle, absPath) {
20784
+ if (!handle) return;
20785
+ try {
20786
+ const info = statSync2(absPath);
20787
+ const mtimeMs = Math.floor(info.mtimeMs);
20788
+ handle.set(absPath, {
20789
+ mtimeMs,
20790
+ size: info.size,
20791
+ readAt: mtimeMs
20792
+ });
20793
+ } catch {
20794
+ }
20795
+ }
20796
+ function enforceReadGate(handle, absPath) {
20797
+ if (!handle) return { ok: true };
20798
+ if (!handle.has(absPath)) {
20799
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
20800
+ }
20801
+ const recorded = handle.get(absPath);
20802
+ if (!recorded) {
20803
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
20804
+ }
20805
+ let info;
20806
+ try {
20807
+ info = statSync2(absPath);
20808
+ } catch {
20809
+ return { ok: true };
20810
+ }
20811
+ const currentMtime = Math.floor(info.mtimeMs);
20812
+ if (currentMtime > recorded.mtimeMs || info.size !== recorded.size) {
20813
+ return { ok: false, message: MODIFIED_SINCE_READ_MESSAGE };
20814
+ }
20815
+ return { ok: true };
20816
+ }
20817
+
20294
20818
  // src/facade/bot/actions/edit.ts
20295
20819
  var ExactThenFuzzyStrategy = class {
20296
20820
  find(content, oldText) {
@@ -20311,7 +20835,12 @@ function rejectWith(message) {
20311
20835
  var editSchema = Type3.Object({
20312
20836
  path: Type3.String({ description: "File to modify, given as a relative or absolute path" }),
20313
20837
  oldText: Type3.String({ description: "The current text to locate; it must be reproduced verbatim" }),
20314
- newText: Type3.String({ description: "The text that should take the place of oldText" })
20838
+ newText: Type3.String({ description: "The text that should take the place of oldText" }),
20839
+ replaceAll: Type3.Optional(
20840
+ Type3.Boolean({
20841
+ description: "Replace every occurrence of oldText instead of requiring a single unique match. Defaults to false, which refuses the edit when oldText appears more than once."
20842
+ })
20843
+ )
20315
20844
  });
20316
20845
  var defaultEditOperations = {
20317
20846
  readFile: (path9) => fsReadFile(path9),
@@ -20353,38 +20882,76 @@ function withAbort(signal, task) {
20353
20882
  });
20354
20883
  }
20355
20884
  var EditSession = class {
20356
- constructor(input, absolutePath, ops, matchingStrategy) {
20885
+ constructor(input, absolutePath, ops, matchingStrategy, cwd, readState, checkpoint) {
20357
20886
  this.input = input;
20358
20887
  this.absolutePath = absolutePath;
20359
20888
  this.ops = ops;
20360
20889
  this.matchingStrategy = matchingStrategy;
20890
+ this.cwd = cwd;
20891
+ this.readState = readState;
20892
+ this.checkpoint = checkpoint;
20893
+ this.replaceAll = input.replaceAll === true;
20361
20894
  }
20362
20895
  input;
20363
20896
  absolutePath;
20364
20897
  ops;
20365
20898
  matchingStrategy;
20899
+ cwd;
20900
+ readState;
20901
+ checkpoint;
20902
+ /** Whether every occurrence should be swapped (vs. requiring a unique hit). */
20903
+ replaceAll;
20366
20904
  async run(throwIfAborted2) {
20367
20905
  throwIfAborted2();
20906
+ const gate = enforceReadGate(this.readState, this.absolutePath);
20907
+ if (!gate.ok) {
20908
+ rejectWith(gate.message);
20909
+ }
20910
+ recordCheckpoint(this.checkpoint, this.absolutePath);
20368
20911
  const { sourceText } = await this.loadSource();
20369
20912
  throwIfAborted2();
20370
20913
  const normalized = this.normalizeInputs(sourceText);
20371
- const match = this.locateUniqueMatch(normalized);
20372
- const replacement = this.applyReplacement(normalized, match);
20914
+ const search = this.resolveSearch(normalized);
20915
+ const match = this.locateUniqueMatch(normalized, search);
20916
+ const replacement = this.applyReplacement(normalized, search, match);
20373
20917
  throwIfAborted2();
20374
20918
  await this.persist(replacement.finalContent);
20375
20919
  throwIfAborted2();
20376
- return this.buildResponse(replacement.editableBase, replacement.replacedContent);
20920
+ return this.buildResponse(replacement.editableBase, replacement.replacedContent, replacement.replacements);
20377
20921
  }
20378
20922
  async loadSource() {
20379
20923
  try {
20380
20924
  await this.ops.access(this.absolutePath);
20381
20925
  } catch {
20382
- rejectWith(`No readable file at ${this.input.path}.`);
20926
+ rejectWith(await this.buildNotFoundMessage());
20383
20927
  }
20384
20928
  const buffer = await this.ops.readFile(this.absolutePath);
20385
20929
  const sourceText = buffer.toString("utf-8");
20386
20930
  return { sourceText };
20387
20931
  }
20932
+ /**
20933
+ * Compose the "No readable file …" rejection, enriched with a "Did you mean
20934
+ * …?" hint when we can spot either a sibling file sharing the base name or a
20935
+ * corrected path re-rooted under the working directory. Falls back to the
20936
+ * bare message when no suggestion applies, so the default behaviour is
20937
+ * preserved for the common case.
20938
+ */
20939
+ async buildNotFoundMessage() {
20940
+ let message = `No readable file at ${this.input.path}. ${FILE_NOT_FOUND_CWD_NOTE} ${this.cwd}.`;
20941
+ try {
20942
+ const cwdSuggestion = await suggestPathUnderCwd(this.absolutePath, this.cwd);
20943
+ if (cwdSuggestion) {
20944
+ message += ` Did you mean ${cwdSuggestion}?`;
20945
+ return message;
20946
+ }
20947
+ const similarFilename = findSimilarFile(this.absolutePath);
20948
+ if (similarFilename) {
20949
+ message += ` Did you mean ${similarFilename}?`;
20950
+ }
20951
+ } catch {
20952
+ }
20953
+ return message;
20954
+ }
20388
20955
  normalizeInputs(sourceText) {
20389
20956
  const { bom, text } = stripBom(sourceText);
20390
20957
  const originalEnding = detectLineEnding(text);
@@ -20399,41 +20966,106 @@ var EditSession = class {
20399
20966
  normalizedNewText
20400
20967
  };
20401
20968
  }
20402
- locateUniqueMatch(normalized) {
20403
- const matchResult = this.matchingStrategy.find(normalized.normalizedContent, normalized.normalizedOldText);
20969
+ /**
20970
+ * Decide which oldText/newText pair to actually edit with. The literal
20971
+ * normalized inputs win whenever they appear in the file; only when they are
20972
+ * absent do we try a de-sanitized variant (repairing mangled tag tokens like
20973
+ * `<o>` → `<output>`), mirroring the same token expansions onto newText so
20974
+ * the replacement stays consistent. The de-sanitize table overlaps ordinary
20975
+ * code, so it is deliberately a *fallback*, never the first attempt.
20976
+ */
20977
+ resolveSearch(normalized) {
20978
+ const literal = {
20979
+ searchOldText: normalized.normalizedOldText,
20980
+ searchNewText: normalized.normalizedNewText
20981
+ };
20982
+ if (this.matchingStrategy.find(normalized.normalizedContent, normalized.normalizedOldText).found) {
20983
+ return literal;
20984
+ }
20985
+ const { result: desanitizedOldText, appliedReplacements } = desanitizeMatchString(
20986
+ normalized.normalizedOldText
20987
+ );
20988
+ if (appliedReplacements.length === 0) {
20989
+ return literal;
20990
+ }
20991
+ if (!this.matchingStrategy.find(normalized.normalizedContent, desanitizedOldText).found) {
20992
+ return literal;
20993
+ }
20994
+ let desanitizedNewText = normalized.normalizedNewText;
20995
+ for (const { from, to } of appliedReplacements) {
20996
+ desanitizedNewText = desanitizedNewText.split(from).join(to);
20997
+ }
20998
+ return { searchOldText: desanitizedOldText, searchNewText: desanitizedNewText };
20999
+ }
21000
+ locateUniqueMatch(normalized, search) {
21001
+ const matchResult = this.matchingStrategy.find(normalized.normalizedContent, search.searchOldText);
20404
21002
  if (!matchResult.found) {
20405
21003
  rejectWith(
20406
21004
  `The supplied text was not located in ${this.input.path}. It has to line up character for character, including every space and line break.`
20407
21005
  );
20408
21006
  }
20409
21007
  const fuzzyContent = normalizeForFuzzyMatch(normalized.normalizedContent);
20410
- const fuzzyOldText = normalizeForFuzzyMatch(normalized.normalizedOldText);
21008
+ const fuzzyOldText = normalizeForFuzzyMatch(search.searchOldText);
20411
21009
  const matchCount = fuzzyContent.split(fuzzyOldText).length - 1;
20412
- if (matchCount > 1) {
21010
+ if (matchCount > 1 && !this.replaceAll) {
20413
21011
  rejectWith(
20414
- `The text appears ${matchCount} times in ${this.input.path}, so the target is ambiguous. Include surrounding lines so it matches in exactly one place.`
21012
+ `The text appears ${matchCount} times in ${this.input.path}, so the target is ambiguous. Include surrounding lines so it matches in exactly one place, or set replaceAll to true to change every occurrence.`
20415
21013
  );
20416
21014
  }
20417
21015
  return { matchResult, matchCount };
20418
21016
  }
20419
- applyReplacement(normalized, match) {
20420
- const editableBase = match.matchResult.contentForReplacement;
20421
- const replacedContent = editableBase.substring(0, match.matchResult.index) + normalized.normalizedNewText + editableBase.substring(match.matchResult.index + match.matchResult.matchLength);
21017
+ applyReplacement(normalized, search, match) {
21018
+ const usedFuzzy = match.matchResult.usedFuzzyMatch;
21019
+ const matchedNeedle = usedFuzzy ? this.recoverOriginalSpan(normalized.normalizedContent, search.searchOldText) : search.searchOldText;
21020
+ const editableBase = normalized.normalizedContent;
21021
+ const replacementText = usedFuzzy ? preserveQuoteStyle(search.searchOldText, matchedNeedle, search.searchNewText) : search.searchNewText;
21022
+ const firstIndex = editableBase.indexOf(matchedNeedle);
21023
+ if (firstIndex === -1) {
21024
+ rejectWith(
21025
+ `The supplied text was not located in ${this.input.path}. It has to line up character for character, including every space and line break.`
21026
+ );
21027
+ }
21028
+ const replacedContent = this.replaceAll ? replaceAllLiteral(editableBase, matchedNeedle, replacementText) : editableBase.substring(0, firstIndex) + replacementText + editableBase.substring(firstIndex + matchedNeedle.length);
20422
21029
  if (editableBase === replacedContent) {
20423
21030
  rejectWith(
20424
21031
  `The edit left ${this.input.path} unchanged because the replacement matches the original. Often this points to unexpected special characters or text that is not actually present.`
20425
21032
  );
20426
21033
  }
21034
+ const replacements = this.replaceAll ? Math.max(match.matchCount, 1) : 1;
20427
21035
  const finalContent = normalized.bom + restoreLineEndings(replacedContent, normalized.originalEnding);
20428
- return { editableBase, replacedContent, finalContent };
21036
+ return { editableBase, replacedContent, finalContent, replacements };
21037
+ }
21038
+ /**
21039
+ * Recover the original (un-folded) span in `content` whose glyph-folded form
21040
+ * equals the folded `searchOldText`. Mirrors induscode's `findActualString`:
21041
+ * locate the match in fully-folded space, then slice the same window out of
21042
+ * the original content so curly quotes and other glyphs are preserved. Falls
21043
+ * back to the supplied text if the window can't be recovered.
21044
+ */
21045
+ recoverOriginalSpan(content, searchOldText) {
21046
+ const foldedContent = normalizeForFuzzyMatch(content);
21047
+ const foldedNeedle = normalizeForFuzzyMatch(searchOldText);
21048
+ const at = foldedContent.indexOf(foldedNeedle);
21049
+ if (at === -1) {
21050
+ return searchOldText;
21051
+ }
21052
+ for (let len = foldedNeedle.length; at + len <= content.length; len++) {
21053
+ const candidate = content.slice(at, at + len);
21054
+ if (normalizeForFuzzyMatch(candidate) === foldedNeedle) {
21055
+ return candidate;
21056
+ }
21057
+ }
21058
+ return content.slice(at, at + foldedNeedle.length);
20429
21059
  }
20430
21060
  async persist(finalContent) {
20431
21061
  await this.ops.writeFile(this.absolutePath, finalContent);
21062
+ recordReadState(this.readState, this.absolutePath);
20432
21063
  }
20433
- buildResponse(editableBase, replacedContent) {
21064
+ buildResponse(editableBase, replacedContent, replacements) {
20434
21065
  const diffResult = generateDiffString(editableBase, replacedContent);
21066
+ const summary = replacements > 1 ? `Replaced the target text in ${this.input.path} (${replacements} occurrences).` : `Replaced the target text in ${this.input.path}.`;
20435
21067
  return {
20436
- content: [{ type: "text", text: `Replaced the target text in ${this.input.path}.` }],
21068
+ content: [{ type: "text", text: summary }],
20437
21069
  details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }
20438
21070
  };
20439
21071
  }
@@ -20451,12 +21083,20 @@ function createEditTool(cwd, options) {
20451
21083
  return {
20452
21084
  name: "edit",
20453
21085
  label: "edit",
20454
- description: "Make a targeted change to a file by swapping one exact span of text for another. Provide oldText that reproduces the existing content character for character, whitespace included. Best for narrow, precise edits.",
21086
+ description: "Make a targeted change to a file by swapping one exact span of text for another. Provide oldText that reproduces the existing content character for character, whitespace included. Best for narrow, precise edits. By default oldText must match exactly once; set replaceAll to true to change every occurrence instead.",
20455
21087
  parameters: editSchema,
20456
21088
  execute: async (_toolCallId, input, signal) => {
20457
21089
  validator.validate(input.path, input.oldText);
20458
21090
  const absolutePath = resolveToCwd(input.path, cwd);
20459
- const session = new EditSession(input, absolutePath, ops, matchingStrategy);
21091
+ const session = new EditSession(
21092
+ input,
21093
+ absolutePath,
21094
+ ops,
21095
+ matchingStrategy,
21096
+ cwd,
21097
+ options?.readState,
21098
+ options?.checkpoint
21099
+ );
20460
21100
  return withAbort(signal, async ({ throwIfAborted: throwIfAborted2 }) => session.run(throwIfAborted2));
20461
21101
  }
20462
21102
  };
@@ -20465,28 +21105,96 @@ var editTool = createEditTool(process.cwd());
20465
21105
 
20466
21106
  // src/facade/bot/actions/find.ts
20467
21107
  import { Type as Type4 } from "@sinclair/typebox";
20468
- import { existsSync as existsSync3 } from "fs";
21108
+ import { existsSync as existsSync3, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
20469
21109
  import path from "path";
20470
21110
  var findSchema = Type4.Object({
20471
21111
  pattern: Type4.String({
20472
21112
  description: 'Glob that the filenames should match, such as "*.ts", "**/*.json", or "lib/**/*.test.ts"'
20473
21113
  }),
20474
21114
  path: Type4.Optional(Type4.String({ description: "Directory tree to traverse. Falls back to the working directory." })),
21115
+ type: Type4.Optional(
21116
+ Type4.String({
21117
+ description: `Restrict results to a language/category (for example "ts", "js", "py"). Filters by the entry's file extension. Defaults to no type filter.`
21118
+ })
21119
+ ),
20475
21120
  limit: Type4.Optional(Type4.Number({ description: "Maximum count of paths handed back. Defaults to 1024." }))
20476
21121
  });
20477
21122
  var DEFAULT_LIMIT = 1024;
20478
21123
  var DEFAULT_EXCLUDES = ["node_modules", ".git"];
20479
21124
  var MAX_DIRECTORY_DEPTH = 20;
21125
+ function escapeRegexLiteral(literal) {
21126
+ return literal.replace(/[\\.*+?^${}()|[\]]/g, "\\$&");
21127
+ }
21128
+ function compileGlob(pattern) {
21129
+ let body = "";
21130
+ for (let i = 0; i < pattern.length; i++) {
21131
+ const ch = pattern[i];
21132
+ if (ch === "*") {
21133
+ if (pattern[i + 1] === "*") {
21134
+ i++;
21135
+ if (pattern[i + 1] === "/") {
21136
+ i++;
21137
+ body += "(?:[^/]*/)*";
21138
+ } else {
21139
+ body += ".*";
21140
+ }
21141
+ } else {
21142
+ body += "[^/]*";
21143
+ }
21144
+ } else if (ch === "?") {
21145
+ body += "[^/]";
21146
+ } else {
21147
+ body += escapeRegexLiteral(ch);
21148
+ }
21149
+ }
21150
+ try {
21151
+ return new RegExp(`^${body}$`);
21152
+ } catch {
21153
+ return null;
21154
+ }
21155
+ }
20480
21156
  var GlobMatcher = class {
20481
- regex;
21157
+ relRegex;
21158
+ baseRegex;
21159
+ hasSeparator;
20482
21160
  constructor(pattern) {
20483
- const regexPattern = pattern.replace(/\*/g, ".*").replace(/\?/g, ".").replace(/\./g, "\\.");
20484
- this.regex = new RegExp(`^${regexPattern}$`);
20485
- }
20486
- matches(value) {
20487
- return this.regex.test(value);
21161
+ this.hasSeparator = pattern.includes("/");
21162
+ this.relRegex = compileGlob(pattern);
21163
+ this.baseRegex = this.hasSeparator ? null : this.relRegex;
21164
+ }
21165
+ /** Test against the root-relative path (preferred), falling back to basename. */
21166
+ matchesPath(relativePath) {
21167
+ const normalized = relativePath.split(path.sep).join("/");
21168
+ if (this.relRegex && this.relRegex.test(normalized)) {
21169
+ return true;
21170
+ }
21171
+ if (this.baseRegex) {
21172
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
21173
+ return this.baseRegex.test(base);
21174
+ }
21175
+ return false;
20488
21176
  }
20489
21177
  };
21178
+ function typeToExtensions(type) {
21179
+ const key = type.trim().toLowerCase();
21180
+ if (key.length === 0) return null;
21181
+ const TABLE = {
21182
+ ts: ["ts", "tsx", "mts", "cts"],
21183
+ js: ["js", "jsx", "mjs", "cjs"],
21184
+ py: ["py", "pyi"],
21185
+ rs: ["rs"],
21186
+ go: ["go"],
21187
+ java: ["java"],
21188
+ c: ["c", "h"],
21189
+ cpp: ["cpp", "cc", "cxx", "hpp", "hh"],
21190
+ md: ["md", "markdown"],
21191
+ json: ["json"],
21192
+ yaml: ["yaml", "yml"],
21193
+ sh: ["sh", "bash", "zsh"]
21194
+ };
21195
+ const exts = TABLE[key] ?? [key];
21196
+ return new Set(exts.map((e) => e.toLowerCase()));
21197
+ }
20490
21198
  var DirectoryWalkPolicy = class {
20491
21199
  constructor(limit) {
20492
21200
  this.limit = limit;
@@ -20500,39 +21208,50 @@ var DirectoryWalkPolicy = class {
20500
21208
  }
20501
21209
  };
20502
21210
  var DirectoryFinder = class {
20503
- constructor(matcher, walkPolicy, limit) {
21211
+ constructor(matcher, walkPolicy, limit, root, typeExtensions) {
20504
21212
  this.matcher = matcher;
20505
21213
  this.walkPolicy = walkPolicy;
20506
21214
  this.limit = limit;
21215
+ this.root = root;
21216
+ this.typeExtensions = typeExtensions;
20507
21217
  }
20508
21218
  matcher;
20509
21219
  walkPolicy;
20510
21220
  limit;
20511
- fs = __require("fs");
21221
+ root;
21222
+ typeExtensions;
20512
21223
  async find(dir) {
20513
21224
  const results = [];
20514
21225
  await this.searchRecursive(dir, 0, results);
20515
21226
  return results;
20516
21227
  }
21228
+ extensionOf(name) {
21229
+ const dot = name.lastIndexOf(".");
21230
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
21231
+ }
20517
21232
  async searchRecursive(currentDir, depth, results) {
20518
21233
  if (!this.walkPolicy.canContinue(results.length, depth)) {
20519
21234
  return;
20520
21235
  }
20521
21236
  try {
20522
- const entries = this.fs.readdirSync(currentDir);
21237
+ const entries = readdirSync2(currentDir);
20523
21238
  for (const entry of entries) {
20524
21239
  if (results.length >= this.limit) {
20525
21240
  break;
20526
21241
  }
20527
21242
  const fullPath = path.join(currentDir, entry);
20528
- const stat2 = this.fs.statSync(fullPath);
20529
- if (stat2.isDirectory()) {
21243
+ const stat3 = statSync3(fullPath);
21244
+ if (stat3.isDirectory()) {
20530
21245
  if (this.walkPolicy.shouldDescend(entry)) {
20531
21246
  await this.searchRecursive(fullPath, depth + 1, results);
20532
21247
  }
20533
21248
  continue;
20534
21249
  }
20535
- if (this.matcher.matches(entry)) {
21250
+ if (this.typeExtensions && !this.typeExtensions.has(this.extensionOf(entry))) {
21251
+ continue;
21252
+ }
21253
+ const relativePath = path.relative(this.root, fullPath);
21254
+ if (this.matcher.matchesPath(relativePath)) {
20536
21255
  results.push(fullPath);
20537
21256
  }
20538
21257
  }
@@ -20587,9 +21306,10 @@ function createFindTool(cwd, options) {
20587
21306
  return {
20588
21307
  name: "find",
20589
21308
  label: "find",
21309
+ readOnly: true,
20590
21310
  description: `Find files whose names match a glob, returning each result as a path relative to the directory that was searched. The listing halts at ${DEFAULT_LIMIT} paths or ${DEFAULT_MAX_BYTES / 1024}KB of output, whichever it reaches first.`,
20591
21311
  parameters: findSchema,
20592
- execute: async (_toolCallId, { pattern, path: searchDir, limit }, signal) => {
21312
+ execute: async (_toolCallId, { pattern, path: searchDir, type, limit }, signal) => {
20593
21313
  return new Promise((resolve5, reject) => {
20594
21314
  if (signal?.aborted) {
20595
21315
  reject(new Error("Operation aborted"));
@@ -20608,7 +21328,8 @@ function createFindTool(cwd, options) {
20608
21328
  }
20609
21329
  const matcher = new GlobMatcher(pattern);
20610
21330
  const walkPolicy = new DirectoryWalkPolicy(effectiveLimit);
20611
- const finder = new DirectoryFinder(matcher, walkPolicy, effectiveLimit);
21331
+ const typeExtensions = type ? typeToExtensions(type) : null;
21332
+ const finder = new DirectoryFinder(matcher, walkPolicy, effectiveLimit, searchPath, typeExtensions);
20612
21333
  let results = await finder.find(searchPath);
20613
21334
  results = results.filter(
20614
21335
  (entryPath) => ![...excludes].some((excluded) => entryPath.includes(`/${excluded}/`) || entryPath.endsWith(`/${excluded}`))
@@ -20634,7 +21355,7 @@ var findTool = createFindTool(process.cwd());
20634
21355
 
20635
21356
  // src/facade/bot/actions/grep.ts
20636
21357
  import { Type as Type5 } from "@sinclair/typebox";
20637
- import { readFileSync, statSync } from "fs";
21358
+ import { readFileSync as readFileSync2, statSync as statSync4, readdirSync as readdirSync3 } from "fs";
20638
21359
  import path2 from "path";
20639
21360
  var grepSchema = Type5.Object({
20640
21361
  pattern: Type5.String({ description: "What to search for, given either as a regular expression or as ordinary text" }),
@@ -20644,20 +21365,158 @@ var grepSchema = Type5.Object({
20644
21365
  Type5.Boolean({ description: "Set to true to treat the pattern as exact text instead of a regex. Defaults to regex mode." })
20645
21366
  ),
20646
21367
  context: Type5.Optional(
20647
- Type5.Number({ description: "Number of neighboring lines to print above and below each hit. Defaults to zero." })
21368
+ Type5.Number({ description: "Number of neighboring lines to print above and below each hit (content mode). Defaults to zero." })
21369
+ ),
21370
+ before: Type5.Optional(
21371
+ Type5.Number({ description: "Number of lines to print BEFORE each hit (-B). Overrides `context` for leading lines." })
21372
+ ),
21373
+ after: Type5.Optional(
21374
+ Type5.Number({ description: "Number of lines to print AFTER each hit (-A). Overrides `context` for trailing lines." })
21375
+ ),
21376
+ output_mode: Type5.Optional(
21377
+ Type5.Union(
21378
+ [Type5.Literal("content"), Type5.Literal("files_with_matches"), Type5.Literal("count")],
21379
+ {
21380
+ description: 'How results are reported: "content" (default) shows matching lines, "files_with_matches" lists matching file paths, "count" reports per-file occurrence totals.'
21381
+ }
21382
+ )
21383
+ ),
21384
+ glob: Type5.Optional(
21385
+ Type5.String({
21386
+ description: 'Only search files whose root-relative path matches this glob (e.g. "*.ts" or "src/**/*.test.ts"). Multiple globs may be comma-separated. Defaults to no glob filter.'
21387
+ })
21388
+ ),
21389
+ type: Type5.Optional(
21390
+ Type5.String({
21391
+ description: 'Only search files of this language/category (for example "ts", "js", "py"), matched by extension. Defaults to no type filter.'
21392
+ })
20648
21393
  ),
20649
21394
  limit: Type5.Optional(Type5.Number({ description: "Upper bound on the count of hits returned. Defaults to 128." }))
20650
21395
  });
20651
21396
  var DEFAULT_LIMIT2 = 128;
20652
21397
  var regexCache = /* @__PURE__ */ new Map();
20653
21398
  var REGEX_CACHE_MAX_SIZE = 256;
20654
- var defaultGrepOperations = {
20655
- isDirectory: (p) => statSync(p).isDirectory(),
20656
- readFile: (p) => readFileSync(p, "utf-8"),
20657
- readdir: (p) => {
20658
- const fs8 = __require("fs");
20659
- return fs8.readdirSync(p);
21399
+ var PRUNED_DIRS = /* @__PURE__ */ new Set([".git", ".svn", ".hg", ".bzr", ".jj", ".sl", "node_modules"]);
21400
+ function escapeGlobLiteral(literal) {
21401
+ return literal.replace(/[\\.*+?^${}()|[\]]/g, "\\$&");
21402
+ }
21403
+ function compileGlob2(glob) {
21404
+ let body = "";
21405
+ for (let i = 0; i < glob.length; i++) {
21406
+ const ch = glob[i];
21407
+ if (ch === "*") {
21408
+ if (glob[i + 1] === "*") {
21409
+ i++;
21410
+ if (glob[i + 1] === "/") {
21411
+ i++;
21412
+ body += "(?:[^/]*/)*";
21413
+ } else {
21414
+ body += ".*";
21415
+ }
21416
+ } else {
21417
+ body += "[^/]*";
21418
+ }
21419
+ } else if (ch === "?") {
21420
+ body += "[^/]";
21421
+ } else {
21422
+ body += escapeGlobLiteral(ch);
21423
+ }
21424
+ }
21425
+ try {
21426
+ return new RegExp(`^${body}$`);
21427
+ } catch {
21428
+ return null;
21429
+ }
21430
+ }
21431
+ function buildIncludePredicate(glob, type) {
21432
+ const globRegexes = [];
21433
+ if (glob && glob.trim().length > 0) {
21434
+ for (const part of glob.split(",")) {
21435
+ const trimmed = part.trim();
21436
+ if (trimmed.length === 0) continue;
21437
+ const re = compileGlob2(trimmed);
21438
+ if (re) globRegexes.push(re);
21439
+ }
21440
+ }
21441
+ const extensions = type ? typeToExtensions2(type) : null;
21442
+ if (globRegexes.length === 0 && !extensions) return null;
21443
+ return (relPath) => {
21444
+ const normalized = relPath.split(path2.sep).join("/");
21445
+ if (globRegexes.length > 0) {
21446
+ const matchesGlob = globRegexes.some((re) => {
21447
+ if (re.test(normalized)) return true;
21448
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
21449
+ return re.test(base);
21450
+ });
21451
+ if (!matchesGlob) return false;
21452
+ }
21453
+ if (extensions) {
21454
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
21455
+ const dot = base.lastIndexOf(".");
21456
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
21457
+ if (!extensions.has(ext)) return false;
21458
+ }
21459
+ return true;
21460
+ };
21461
+ }
21462
+ function typeToExtensions2(type) {
21463
+ const key = type.trim().toLowerCase();
21464
+ if (key.length === 0) return null;
21465
+ const TABLE = {
21466
+ ts: ["ts", "tsx", "mts", "cts"],
21467
+ js: ["js", "jsx", "mjs", "cjs"],
21468
+ py: ["py", "pyi"],
21469
+ rs: ["rs"],
21470
+ go: ["go"],
21471
+ java: ["java"],
21472
+ c: ["c", "h"],
21473
+ cpp: ["cpp", "cc", "cxx", "hpp", "hh"],
21474
+ md: ["md", "markdown"],
21475
+ json: ["json"],
21476
+ yaml: ["yaml", "yml"],
21477
+ sh: ["sh", "bash", "zsh"]
21478
+ };
21479
+ const exts = TABLE[key] ?? [key];
21480
+ return new Set(exts.map((e) => e.toLowerCase()));
21481
+ }
21482
+ async function loadGitignore(rootDir, readFile3) {
21483
+ let body;
21484
+ try {
21485
+ body = await readFile3(path2.join(rootDir, ".gitignore"));
21486
+ } catch {
21487
+ return () => false;
21488
+ }
21489
+ const regexes = [];
21490
+ for (const raw of body.split("\n")) {
21491
+ const line = raw.trim();
21492
+ if (line.length === 0 || line.startsWith("#")) continue;
21493
+ const anchored = line.startsWith("/");
21494
+ const pat = anchored ? line.slice(1) : line;
21495
+ const re = compileGlob2(pat.replace(/\/$/, ""));
21496
+ if (!re) continue;
21497
+ if (anchored) {
21498
+ regexes.push(new RegExp(`${re.source.slice(0, -1)}(?:/.*)?$`));
21499
+ } else {
21500
+ regexes.push(new RegExp(`(?:^|/)${re.source.slice(1, -1)}(?:/.*)?$`));
21501
+ }
20660
21502
  }
21503
+ if (regexes.length === 0) return () => false;
21504
+ return (relPath) => {
21505
+ const normalized = relPath.split(path2.sep).join("/");
21506
+ return regexes.some((re) => re.test(normalized));
21507
+ };
21508
+ }
21509
+ function looksBinary(text) {
21510
+ const limit = Math.min(text.length, 4096);
21511
+ for (let i = 0; i < limit; i++) {
21512
+ if (text.charCodeAt(i) === 0) return true;
21513
+ }
21514
+ return false;
21515
+ }
21516
+ var defaultGrepOperations = {
21517
+ isDirectory: (p) => statSync4(p).isDirectory(),
21518
+ readFile: (p) => readFileSync2(p, "utf-8"),
21519
+ readdir: (p) => readdirSync3(p)
20661
21520
  };
20662
21521
  var RegexFactory = class {
20663
21522
  static create(pattern, ignoreCase, literal) {
@@ -20678,12 +21537,22 @@ var RegexFactory = class {
20678
21537
  }
20679
21538
  };
20680
21539
  var SearchPlan = class {
20681
- constructor(searchPath, ops) {
21540
+ constructor(searchPath, ops, options) {
20682
21541
  this.searchPath = searchPath;
20683
21542
  this.ops = ops;
21543
+ this.include = options?.include ?? null;
21544
+ this.ignored = options?.ignored ?? null;
20684
21545
  }
20685
21546
  searchPath;
20686
21547
  ops;
21548
+ include;
21549
+ ignored;
21550
+ /**
21551
+ * Fully recursive (depth-first) enumeration of every readable file beneath
21552
+ * the search root. Pruned VCS/dependency folders and dot-directories are
21553
+ * skipped, directories are NEVER pushed as if they were files, and the
21554
+ * optional include/gitignore predicates scope or exclude the result set.
21555
+ */
20687
21556
  async collectFiles() {
20688
21557
  const filesToSearch = [];
20689
21558
  const isDir = await this.ops.isDirectory(this.searchPath);
@@ -20691,38 +21560,57 @@ var SearchPlan = class {
20691
21560
  filesToSearch.push(this.searchPath);
20692
21561
  return filesToSearch;
20693
21562
  }
20694
- const entries = await this.ops.readdir(this.searchPath);
21563
+ await this.walk(this.searchPath, filesToSearch);
21564
+ return filesToSearch;
21565
+ }
21566
+ relativeOf(fullPath) {
21567
+ return path2.relative(this.searchPath, fullPath);
21568
+ }
21569
+ async walk(dir, sink) {
21570
+ let entries;
21571
+ try {
21572
+ entries = await this.ops.readdir(dir);
21573
+ } catch {
21574
+ return;
21575
+ }
21576
+ entries.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
20695
21577
  for (const entry of entries) {
20696
- const fullPath = path2.join(this.searchPath, entry);
21578
+ const fullPath = path2.join(dir, entry);
21579
+ let entryIsDir;
20697
21580
  try {
20698
- if (await this.ops.isDirectory(fullPath) && !entry.startsWith(".")) {
20699
- const subEntries = await this.ops.readdir(fullPath);
20700
- for (const subEntry of subEntries) {
20701
- filesToSearch.push(path2.join(fullPath, subEntry));
20702
- }
20703
- } else if (!await this.ops.isDirectory(fullPath)) {
20704
- filesToSearch.push(fullPath);
20705
- }
21581
+ entryIsDir = await this.ops.isDirectory(fullPath);
20706
21582
  } catch {
21583
+ continue;
21584
+ }
21585
+ const rel = this.relativeOf(fullPath);
21586
+ if (entryIsDir) {
21587
+ if (PRUNED_DIRS.has(entry) || entry.startsWith(".")) continue;
21588
+ if (this.ignored && this.ignored(rel)) continue;
21589
+ await this.walk(fullPath, sink);
21590
+ continue;
20707
21591
  }
21592
+ if (this.ignored && this.ignored(rel)) continue;
21593
+ if (this.include && !this.include(rel)) continue;
21594
+ sink.push(fullPath);
20708
21595
  }
20709
- return filesToSearch;
20710
21596
  }
20711
21597
  };
20712
21598
  var MatchRenderer = class {
20713
- constructor(searchPath, contextValue) {
21599
+ constructor(searchPath, contextValue, before, after) {
20714
21600
  this.searchPath = searchPath;
20715
- this.contextValue = contextValue;
21601
+ this.before = before !== void 0 && before > 0 ? before : contextValue;
21602
+ this.after = after !== void 0 && after > 0 ? after : contextValue;
20716
21603
  }
20717
21604
  searchPath;
20718
- contextValue;
21605
+ before;
21606
+ after;
20719
21607
  renderMatch(filePath, lines, lineIndex) {
20720
21608
  const relativePath = path2.relative(this.searchPath, filePath);
20721
21609
  const line = lines[lineIndex] ?? "";
20722
21610
  const { wasTruncated } = truncateLine(line, GREP_MAX_LINE_LENGTH);
20723
21611
  const output = [];
20724
- const start = Math.max(0, lineIndex - this.contextValue);
20725
- const end = Math.min(lines.length - 1, lineIndex + this.contextValue);
21612
+ const start = Math.max(0, lineIndex - this.before);
21613
+ const end = Math.min(lines.length - 1, lineIndex + this.after);
20726
21614
  for (let j = start; j <= end; j++) {
20727
21615
  const isMatch = j === lineIndex;
20728
21616
  const prefix = isMatch ? ":" : "-";
@@ -20768,12 +21656,32 @@ var MatchRenderer = class {
20768
21656
  details: Object.keys(details).length > 0 ? details : void 0
20769
21657
  };
20770
21658
  }
21659
+ /** `files_with_matches` mode: one root-relative path per matching file. */
21660
+ formatFilesWithMatches(files) {
21661
+ if (files.length === 0) {
21662
+ return { output: "Pattern not found in any file" };
21663
+ }
21664
+ return { output: files.join("\n") };
21665
+ }
21666
+ /** `count` mode: `path:N` lines plus a roll-up of occurrences across files. */
21667
+ formatCount(counts) {
21668
+ if (counts.length === 0) {
21669
+ return { output: "Pattern not found in any file" };
21670
+ }
21671
+ const lines = counts.map((c) => `${c.file}:${c.count}`);
21672
+ const total = counts.reduce((sum, c) => sum + c.count, 0);
21673
+ const fileWord = counts.length === 1 ? "file" : "files";
21674
+ const occWord = total === 1 ? "occurrence" : "occurrences";
21675
+ lines.push("", `Found ${total} ${occWord} across ${counts.length} ${fileWord}`);
21676
+ return { output: lines.join("\n") };
21677
+ }
20771
21678
  };
20772
21679
  function createGrepTool(cwd, options) {
20773
21680
  const customOps = options?.operations;
20774
21681
  return {
20775
21682
  name: "grep",
20776
21683
  label: "grep",
21684
+ readOnly: true,
20777
21685
  description: `Search file contents for a pattern, returning every hit alongside its path and line number. The search halts at ${DEFAULT_LIMIT2} hits or ${DEFAULT_MAX_BYTES / 1024}KB of output, whichever it reaches first. Any line over ${GREP_MAX_LINE_LENGTH} characters is shortened.`,
20778
21686
  parameters: grepSchema,
20779
21687
  execute: async (_toolCallId, {
@@ -20782,6 +21690,11 @@ function createGrepTool(cwd, options) {
20782
21690
  ignoreCase,
20783
21691
  literal,
20784
21692
  context,
21693
+ before,
21694
+ after,
21695
+ output_mode,
21696
+ glob,
21697
+ type,
20785
21698
  limit
20786
21699
  }, signal) => {
20787
21700
  return new Promise((resolve5, reject) => {
@@ -20797,7 +21710,10 @@ function createGrepTool(cwd, options) {
20797
21710
  const ops = customOps ?? defaultGrepOperations;
20798
21711
  const effectiveLimit = limit ?? DEFAULT_LIMIT2;
20799
21712
  const contextValue = context && context > 0 ? context : 0;
20800
- const searchPlan = new SearchPlan(searchPath, ops);
21713
+ const mode = output_mode ?? "content";
21714
+ const include = buildIncludePredicate(glob, type);
21715
+ const ignored = await loadGitignore(searchPath, (p) => ops.readFile(p));
21716
+ const searchPlan = new SearchPlan(searchPath, ops, { include, ignored });
20801
21717
  const filesToSearch = await searchPlan.collectFiles();
20802
21718
  if (filesToSearch.length === 0) {
20803
21719
  signal?.removeEventListener("abort", onAbort);
@@ -20812,34 +21728,63 @@ function createGrepTool(cwd, options) {
20812
21728
  reject(new Error(`Invalid regex pattern: ${e.message}`));
20813
21729
  return;
20814
21730
  }
20815
- const matchRenderer = new MatchRenderer(searchPath, contextValue);
21731
+ const matchRenderer = new MatchRenderer(searchPath, contextValue, before, after);
20816
21732
  const matches = [];
21733
+ const filesWithMatches = [];
21734
+ const counts = [];
20817
21735
  let linesTruncated = false;
21736
+ let stopScanning = false;
20818
21737
  for (const filePath of filesToSearch) {
20819
- if (matches.length >= effectiveLimit) {
21738
+ if (stopScanning) break;
21739
+ if (mode === "content" && matches.length >= effectiveLimit) {
20820
21740
  break;
20821
21741
  }
20822
21742
  try {
20823
21743
  const content = await ops.readFile(filePath);
21744
+ if (looksBinary(content)) {
21745
+ continue;
21746
+ }
20824
21747
  const lines = content.split("\n");
21748
+ const relativePath = path2.relative(searchPath, filePath);
21749
+ let fileCount = 0;
20825
21750
  for (let i = 0; i < lines.length; i++) {
20826
- if (matches.length >= effectiveLimit) {
21751
+ if (mode === "content" && matches.length >= effectiveLimit) {
21752
+ stopScanning = true;
20827
21753
  break;
20828
21754
  }
20829
21755
  const line = lines[i] ?? "";
20830
21756
  if (regex.test(line)) {
20831
- const rendered = matchRenderer.renderMatch(filePath, lines, i);
20832
- if (rendered.lineTruncated) {
20833
- linesTruncated = true;
21757
+ fileCount++;
21758
+ if (mode === "content") {
21759
+ const rendered = matchRenderer.renderMatch(filePath, lines, i);
21760
+ if (rendered.lineTruncated) {
21761
+ linesTruncated = true;
21762
+ }
21763
+ matches.push({ file: relativePath, line: i + 1, text: rendered.text });
21764
+ } else if (mode === "files_with_matches") {
21765
+ break;
20834
21766
  }
20835
- matches.push({ file: path2.relative(searchPath, filePath), line: i + 1, text: rendered.text });
21767
+ }
21768
+ }
21769
+ if (fileCount > 0) {
21770
+ if (mode === "files_with_matches") {
21771
+ filesWithMatches.push(relativePath);
21772
+ } else if (mode === "count") {
21773
+ counts.push({ file: relativePath, count: fileCount });
20836
21774
  }
20837
21775
  }
20838
21776
  } catch {
20839
21777
  }
20840
21778
  }
20841
21779
  signal?.removeEventListener("abort", onAbort);
20842
- const formatted = matchRenderer.formatOutput(matches, effectiveLimit, linesTruncated);
21780
+ let formatted;
21781
+ if (mode === "files_with_matches") {
21782
+ formatted = matchRenderer.formatFilesWithMatches(filesWithMatches);
21783
+ } else if (mode === "count") {
21784
+ formatted = matchRenderer.formatCount(counts);
21785
+ } else {
21786
+ formatted = matchRenderer.formatOutput(matches, effectiveLimit, linesTruncated);
21787
+ }
20843
21788
  resolve5({
20844
21789
  content: [{ type: "text", text: formatted.output }],
20845
21790
  details: formatted.details
@@ -20857,7 +21802,7 @@ var grepTool = createGrepTool(process.cwd());
20857
21802
 
20858
21803
  // src/facade/bot/actions/ls.ts
20859
21804
  import { Type as Type6 } from "@sinclair/typebox";
20860
- import { existsSync as existsSync4, readdirSync, statSync as statSync2 } from "fs";
21805
+ import { existsSync as existsSync4, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
20861
21806
  import nodePath from "path";
20862
21807
  var lsSchema = Type6.Object({
20863
21808
  path: Type6.Optional(Type6.String({ description: "Directory whose entries should be listed. Falls back to the working directory." })),
@@ -20866,8 +21811,8 @@ var lsSchema = Type6.Object({
20866
21811
  var DEFAULT_LIMIT3 = 512;
20867
21812
  var defaultLsOperations = {
20868
21813
  exists: existsSync4,
20869
- stat: statSync2,
20870
- readdir: readdirSync
21814
+ stat: statSync5,
21815
+ readdir: readdirSync4
20871
21816
  };
20872
21817
  function createAbortGuard(signal) {
20873
21818
  let aborted = signal?.aborted ?? false;
@@ -20952,8 +21897,8 @@ var DirectoryListRunner = class {
20952
21897
  if (!await this.ops.exists(resolvedDir)) {
20953
21898
  throw new Error(`No such path: ${resolvedDir}`);
20954
21899
  }
20955
- const stat2 = await this.ops.stat(resolvedDir);
20956
- if (!stat2.isDirectory()) {
21900
+ const stat3 = await this.ops.stat(resolvedDir);
21901
+ if (!stat3.isDirectory()) {
20957
21902
  throw new Error(`Target is not a directory: ${resolvedDir}`);
20958
21903
  }
20959
21904
  }
@@ -21009,6 +21954,7 @@ function createLsTool(cwd, options) {
21009
21954
  return {
21010
21955
  name: "ls",
21011
21956
  label: "ls",
21957
+ readOnly: true,
21012
21958
  description: `List the contents of a directory. Names are returned sorted alphabetically with dotfiles kept in, and subdirectories carry a trailing '/'. The listing halts at ${DEFAULT_LIMIT3} entries or ${DEFAULT_MAX_BYTES / 1024}KB of output, whichever it reaches first.`,
21013
21959
  parameters: lsSchema,
21014
21960
  execute: async (_toolCallId, { path: path9, limit }, signal) => {
@@ -22435,24 +23381,25 @@ function withAbort2(signal, task) {
22435
23381
  });
22436
23382
  }
22437
23383
  var ReadExecutionPipeline = class {
22438
- constructor(cwd, ops, autoResizeImages) {
23384
+ constructor(cwd, ops, autoResizeImages, readState) {
22439
23385
  this.cwd = cwd;
22440
23386
  this.ops = ops;
22441
23387
  this.autoResizeImages = autoResizeImages;
23388
+ this.readState = readState;
22442
23389
  }
22443
23390
  cwd;
22444
23391
  ops;
22445
23392
  autoResizeImages;
23393
+ readState;
22446
23394
  async run(input, throwIfAborted2) {
22447
23395
  const absolutePath = resolveReadPath(input.path, this.cwd);
22448
23396
  await this.ops.access(absolutePath);
22449
23397
  throwIfAborted2();
22450
23398
  const mimeType = this.ops.detectImageMimeType ? await this.ops.detectImageMimeType(absolutePath) : void 0;
22451
23399
  throwIfAborted2();
22452
- if (mimeType) {
22453
- return this.readImageVariant(absolutePath, mimeType, throwIfAborted2);
22454
- }
22455
- return this.readTextVariant(absolutePath, input.path, input.offset, input.limit, throwIfAborted2);
23400
+ const result = mimeType ? await this.readImageVariant(absolutePath, mimeType, throwIfAborted2) : await this.readTextVariant(absolutePath, input.path, input.offset, input.limit, throwIfAborted2);
23401
+ recordReadState(this.readState, absolutePath);
23402
+ return result;
22456
23403
  }
22457
23404
  async readImageVariant(absolutePath, mimeType, throwIfAborted2) {
22458
23405
  const buffer = await this.ops.readFile(absolutePath);
@@ -22500,12 +23447,14 @@ ${dimensionNote}`;
22500
23447
  renderedText = `[Line ${startLineDisplay} weighs ${firstLineSize}, over the ${formatSize(DEFAULT_MAX_BYTES)} cap. Pull a byte slice via bash: sed -n '${startLineDisplay}p' ${requestedPath} | head -c ${DEFAULT_MAX_BYTES}]`;
22501
23448
  details = { truncation };
22502
23449
  } else if (truncation.truncated) {
22503
- renderedText = this.formatTruncationNotice(truncation, totalFileLines, startLineDisplay);
23450
+ const gutteredBody = this.addGutter(truncation.content, startLineDisplay);
23451
+ renderedText = this.formatTruncationNotice(gutteredBody, truncation, totalFileLines, startLineDisplay);
22504
23452
  details = { truncation };
22505
23453
  } else if (requestedLineCount !== void 0 && startLine + requestedLineCount < fileLines.length) {
22506
- renderedText = this.formatUserLimitNotice(truncation.content, fileLines.length, startLine, requestedLineCount);
23454
+ const gutteredBody = this.addGutter(truncation.content, startLineDisplay);
23455
+ renderedText = this.formatUserLimitNotice(gutteredBody, fileLines.length, startLine, requestedLineCount);
22507
23456
  } else {
22508
- renderedText = truncation.content;
23457
+ renderedText = this.addGutter(truncation.content, startLineDisplay);
22509
23458
  }
22510
23459
  throwIfAborted2();
22511
23460
  return {
@@ -22533,14 +23482,30 @@ ${dimensionNote}`;
22533
23482
  requestedLineCount: void 0
22534
23483
  };
22535
23484
  }
22536
- formatTruncationNotice(truncation, totalFileLines, startLineDisplay) {
23485
+ formatTruncationNotice(body, truncation, totalFileLines, startLineDisplay) {
22537
23486
  const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
22538
23487
  const nextOffset = endLineDisplay + 1;
22539
23488
  const continuationHint = this.buildContinuationHint(truncation, totalFileLines, startLineDisplay, endLineDisplay, nextOffset);
22540
- return `${truncation.content}
23489
+ return `${body}
22541
23490
 
22542
23491
  ${continuationHint}`;
22543
23492
  }
23493
+ /**
23494
+ * Prefix each line with a right-aligned 1-based absolute line number in the
23495
+ * cat -n style, giving the model stable edit/diff coordinates. `content` is
23496
+ * already windowed; `startLineDisplay` is the whole-file number of its first
23497
+ * line so the gutter stays correct under offset/limit paging. Empty content
23498
+ * (e.g. binary or an over-budget first line yielding "") passes through
23499
+ * unchanged so non-text output stays stable.
23500
+ */
23501
+ addGutter(content, startLineDisplay) {
23502
+ if (content.length === 0) return content;
23503
+ return content.split(/\r?\n/).map((line, i) => {
23504
+ const numStr = String(startLineDisplay + i);
23505
+ const label = numStr.length >= 6 ? numStr : numStr.padStart(6, " ");
23506
+ return `${label}\u2192${line}`;
23507
+ }).join("\n");
23508
+ }
22544
23509
  formatUserLimitNotice(baseText, totalLines, startLine, requestedLineCount) {
22545
23510
  const remaining = totalLines - (startLine + requestedLineCount);
22546
23511
  const nextOffset = startLine + requestedLineCount + 1;
@@ -22558,11 +23523,12 @@ ${continuationHint}`;
22558
23523
  function createReadTool(cwd, options) {
22559
23524
  const autoResizeImages = options?.autoResizeImages ?? true;
22560
23525
  const ops = options?.operations ?? defaultReadOperations;
22561
- const pipeline = new ReadExecutionPipeline(cwd, ops, autoResizeImages);
23526
+ const pipeline = new ReadExecutionPipeline(cwd, ops, autoResizeImages, options?.readState);
22562
23527
  return {
22563
23528
  name: "read",
22564
23529
  label: "read",
22565
- description: `Open a file and return its contents. Both text and images (jpg, png, gif, webp) work; images come back as attachments. Text output is capped at ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, whichever comes first. For big files, page through with offset and limit, advancing offset until you have read everything you need.`,
23530
+ readOnly: true,
23531
+ description: `Open a file and return its contents. Both text and images (jpg, png, gif, webp) work; images come back as attachments. Output uses cat -n format - each line is prefixed with its 1-based line number. Text output is capped at ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, whichever comes first. For big files, page through with offset and limit, advancing offset until you have read everything you need.`,
22566
23532
  parameters: readSchema,
22567
23533
  execute: async (_toolCallId, { path: path9, offset, limit }, signal) => {
22568
23534
  return withAbort2(signal, async ({ throwIfAborted: throwIfAborted2 }) => {
@@ -22579,9 +23545,9 @@ import { Type as Type15 } from "@sinclair/typebox";
22579
23545
  // src/facade/bot/actions/process-manager.ts
22580
23546
  import { spawn as spawn3 } from "node:child_process";
22581
23547
  import { EventEmitter } from "node:events";
22582
- import { appendFileSync, existsSync as existsSync5, mkdirSync, readFileSync as readFileSync2, rmSync, statSync as statSync3 } from "node:fs";
22583
- import { tmpdir as tmpdir2 } from "node:os";
22584
- import { isAbsolute as isAbsolute2, join as join2 } from "node:path";
23548
+ import { appendFileSync, existsSync as existsSync5, mkdirSync, readFileSync as readFileSync3, rmSync, statSync as statSync6 } from "node:fs";
23549
+ import { tmpdir as tmpdir3 } from "node:os";
23550
+ import { isAbsolute as isAbsolute2, join as join3 } from "node:path";
22585
23551
 
22586
23552
  // src/facade/bot/actions/process-types.ts
22587
23553
  var MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
@@ -22646,7 +23612,7 @@ var ProcessManager = class {
22646
23612
  watcher = null;
22647
23613
  getConfiguredShellPath;
22648
23614
  constructor(options) {
22649
- this.logDir = join2(tmpdir2(), `indusagi-processes-${Date.now()}`);
23615
+ this.logDir = join3(tmpdir3(), `indusagi-processes-${Date.now()}`);
22650
23616
  mkdirSync(this.logDir, { recursive: true });
22651
23617
  this.getConfiguredShellPath = options?.getConfiguredShellPath ?? (() => void 0);
22652
23618
  }
@@ -22715,9 +23681,9 @@ var ProcessManager = class {
22715
23681
  }
22716
23682
  start(name, command, cwd, options) {
22717
23683
  const id = `proc_${++this.counter}`;
22718
- const stdoutFile = join2(this.logDir, `${id}-stdout.log`);
22719
- const stderrFile = join2(this.logDir, `${id}-stderr.log`);
22720
- const combinedFile = join2(this.logDir, `${id}-combined.log`);
23684
+ const stdoutFile = join3(this.logDir, `${id}-stdout.log`);
23685
+ const stderrFile = join3(this.logDir, `${id}-stderr.log`);
23686
+ const combinedFile = join3(this.logDir, `${id}-combined.log`);
22721
23687
  appendFileSync(stdoutFile, "");
22722
23688
  appendFileSync(stderrFile, "");
22723
23689
  appendFileSync(combinedFile, "");
@@ -22867,8 +23833,8 @@ var ProcessManager = class {
22867
23833
  }
22868
23834
  try {
22869
23835
  return {
22870
- stdout: readFileSync2(managed.stdoutFile, "utf-8"),
22871
- stderr: readFileSync2(managed.stderrFile, "utf-8")
23836
+ stdout: readFileSync3(managed.stdoutFile, "utf-8"),
23837
+ stderr: readFileSync3(managed.stderrFile, "utf-8")
22872
23838
  };
22873
23839
  } catch {
22874
23840
  return { stdout: "", stderr: "" };
@@ -23024,8 +23990,8 @@ var ProcessManager = class {
23024
23990
  }
23025
23991
  try {
23026
23992
  return {
23027
- stdout: statSync3(managed.stdoutFile).size,
23028
- stderr: statSync3(managed.stderrFile).size
23993
+ stdout: statSync6(managed.stdoutFile).size,
23994
+ stderr: statSync6(managed.stderrFile).size
23029
23995
  };
23030
23996
  } catch {
23031
23997
  return { stdout: 0, stderr: 0 };
@@ -23033,7 +23999,7 @@ var ProcessManager = class {
23033
23999
  }
23034
24000
  readTailLines(filePath, lines) {
23035
24001
  try {
23036
- const content = readFileSync2(filePath, "utf-8");
24002
+ const content = readFileSync3(filePath, "utf-8");
23037
24003
  const allLines = content.split("\n");
23038
24004
  if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
23039
24005
  allLines.pop();
@@ -23596,6 +24562,7 @@ function createTodoReadTool(store) {
23596
24562
  return {
23597
24563
  name: "todoread",
23598
24564
  label: "todoread",
24565
+ readOnly: true,
23599
24566
  description: "Read the current todo list. Returns items with content, status, and priority.",
23600
24567
  parameters: TodoReadSchema,
23601
24568
  execute: async () => {
@@ -23736,6 +24703,7 @@ function createWebFetchTool(options) {
23736
24703
  return {
23737
24704
  name: "webfetch",
23738
24705
  label: "webfetch",
24706
+ readOnly: true,
23739
24707
  description: "Fetches content from a specified URL. Takes a URL and optional format as input. Fetches URL content, converts to requested format (markdown by default). Returns content in the specified format. Use this tool when you need to retrieve and analyze web content. The URL must be a fully-formed valid URL starting with http:// or https://. Format options: 'markdown' (default), 'text', or 'html'. This tool is read-only and does not modify any files.",
23740
24708
  parameters: webFetchSchema,
23741
24709
  execute: async (_toolCallId, params, signal) => {
@@ -23832,6 +24800,62 @@ var webFetchTool = createWebFetchTool();
23832
24800
 
23833
24801
  // src/facade/bot/actions/websearch.ts
23834
24802
  import { Type as Type18 } from "@sinclair/typebox";
24803
+ var SEARCH_ENDPOINT = "https://html.duckduckgo.com/html/";
24804
+ var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
24805
+ function toPlainText(html) {
24806
+ const withoutTags = html.replace(/<[^>]*>/g, "");
24807
+ const decoded = withoutTags.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'").replace(/&nbsp;/g, " ");
24808
+ return decoded.replace(/\s+/g, " ").trim();
24809
+ }
24810
+ function unwrapTarget(href) {
24811
+ let candidate = href;
24812
+ const redirectMatch = /[?&]uddg=([^&]+)/.exec(candidate);
24813
+ if (redirectMatch) {
24814
+ try {
24815
+ candidate = decodeURIComponent(redirectMatch[1]);
24816
+ } catch {
24817
+ }
24818
+ }
24819
+ if (candidate.startsWith("//")) {
24820
+ candidate = `https:${candidate}`;
24821
+ }
24822
+ return candidate;
24823
+ }
24824
+ function extractHits(body, limit) {
24825
+ const hits = [];
24826
+ const seen = /* @__PURE__ */ new Set();
24827
+ const titleAnchor = /<a\b[^>]*\bclass="[^"]*\bresult__a\b[^"]*"[^>]*\bhref="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
24828
+ const snippetBlock = /\bclass="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/[a-z]+>/i;
24829
+ let match;
24830
+ while ((match = titleAnchor.exec(body)) !== null) {
24831
+ if (hits.length >= limit) break;
24832
+ const url = unwrapTarget(match[1]);
24833
+ const title = toPlainText(match[2]);
24834
+ if (url.length === 0 || title.length === 0) continue;
24835
+ if (seen.has(url)) continue;
24836
+ const tail = body.slice(titleAnchor.lastIndex);
24837
+ const snippetMatch = snippetBlock.exec(tail);
24838
+ const snippet = snippetMatch ? toPlainText(snippetMatch[1]) : "";
24839
+ seen.add(url);
24840
+ hits.push({ title, url, snippet });
24841
+ }
24842
+ return hits;
24843
+ }
24844
+ function renderHits(query, hits) {
24845
+ const lines = [
24846
+ `${hits.length} result${hits.length === 1 ? "" : "s"} for "${query}":`,
24847
+ ""
24848
+ ];
24849
+ hits.forEach((hit, index) => {
24850
+ lines.push(`${index + 1}. ${hit.title}`);
24851
+ lines.push(` ${hit.url}`);
24852
+ if (hit.snippet.length > 0) {
24853
+ lines.push(` ${hit.snippet}`);
24854
+ }
24855
+ lines.push("");
24856
+ });
24857
+ return lines.join("\n").trimEnd();
24858
+ }
23835
24859
  var webSearchSchema = Type18.Object({
23836
24860
  query: Type18.String({ description: "Web search query" }),
23837
24861
  numResults: Type18.Optional(
@@ -23843,48 +24867,31 @@ var requestCount = 0;
23843
24867
  async function defaultWebSearch(query, numResults, signal) {
23844
24868
  const count = Math.min(numResults ?? 8, 10);
23845
24869
  try {
23846
- const searchUrl = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
23847
- const response = await fetch(searchUrl, {
24870
+ const form = new URLSearchParams({ q: query });
24871
+ const response = await fetch(SEARCH_ENDPOINT, {
24872
+ method: "POST",
23848
24873
  signal,
23849
24874
  headers: {
23850
- "Accept": "application/json"
23851
- }
24875
+ "Content-Type": "application/x-www-form-urlencoded",
24876
+ "User-Agent": USER_AGENT,
24877
+ Accept: "text/html"
24878
+ },
24879
+ body: form.toString()
23852
24880
  });
23853
24881
  if (!response.ok) {
23854
24882
  throw new Error(`Search request failed: ${response.status}`);
23855
24883
  }
23856
- const data = await response.json();
23857
- const results = [];
23858
- if (data.AbstractText) {
23859
- results.push(`**Summary:** ${data.AbstractText}`);
23860
- if (data.AbstractURL) {
23861
- results.push(`Source: ${data.AbstractURL}`);
23862
- }
23863
- }
23864
- if (data.RelatedTopics && Array.isArray(data.RelatedTopics)) {
23865
- const topics = data.RelatedTopics.filter((t) => t.Text && t.FirstURL).slice(0, count);
23866
- if (topics.length > 0) {
23867
- results.push(`
23868
- **Related Results:**`);
23869
- for (const topic of topics) {
23870
- results.push(`- ${topic.Text}`);
23871
- results.push(` URL: ${topic.FirstURL}`);
23872
- }
23873
- }
23874
- }
23875
- if (results.length === 0) {
24884
+ const body = await response.text();
24885
+ const hits = extractHits(body, count);
24886
+ if (hits.length === 0) {
23876
24887
  return `No results found for: "${query}". Try a different search query.`;
23877
24888
  }
23878
- return `Web search results for: "${query}"
23879
-
23880
- ${results.join("\n")}`;
24889
+ return renderHits(query, hits);
23881
24890
  } catch (error) {
23882
- if (error.name === "AbortError") {
24891
+ if (error?.name === "AbortError") {
23883
24892
  throw new Error("Web search request timed out or was aborted");
23884
24893
  }
23885
- return `Search query: "${query}"
23886
-
23887
- Unable to perform live search. Please try again or use a different query.`;
24894
+ throw error;
23888
24895
  }
23889
24896
  }
23890
24897
  function createWebSearchTool(options) {
@@ -23892,6 +24899,7 @@ function createWebSearchTool(options) {
23892
24899
  return {
23893
24900
  name: "websearch",
23894
24901
  label: "websearch",
24902
+ readOnly: true,
23895
24903
  description: "Search the web - performs real-time web searches and can scrape content from specific URLs. Provides up-to-date information for current events and recent data. Use this tool for accessing information beyond knowledge cutoff. The current year is 2026. You MUST use this year when searching for recent information or current events (e.g., search for 'AI news 2026', NOT 'AI news 2025').",
23896
24904
  parameters: webSearchSchema,
23897
24905
  execute: async (_toolCallId, { query, numResults }, signal) => {
@@ -23950,8 +24958,9 @@ var webSearchTool = createWebSearchTool();
23950
24958
 
23951
24959
  // src/facade/bot/actions/write.ts
23952
24960
  import { Type as Type19 } from "@sinclair/typebox";
24961
+ import { existsSync as existsSync6 } from "fs";
23953
24962
  import { copyFile as fsCopyFile, mkdir as fsMkdir, writeFile as fsWriteFile2 } from "fs/promises";
23954
- import { dirname } from "path";
24963
+ import { dirname as dirname2 } from "path";
23955
24964
  var writeSchema = Type19.Object({
23956
24965
  path: Type19.String({ description: "Destination file, given as a relative or absolute path" }),
23957
24966
  content: Type19.String({ description: "The text that will become the file's contents" })
@@ -23970,22 +24979,48 @@ var FileValidator = class {
23970
24979
  }
23971
24980
  };
23972
24981
  var WriteSession = class {
23973
- constructor(cwd, relativePath, fileContent, operations, createBackup) {
24982
+ constructor(cwd, relativePath, fileContent, operations, createBackup, readState, checkpoint) {
23974
24983
  this.cwd = cwd;
23975
24984
  this.relativePath = relativePath;
23976
24985
  this.fileContent = fileContent;
23977
24986
  this.operations = operations;
23978
24987
  this.createBackup = createBackup;
24988
+ this.readState = readState;
24989
+ this.checkpoint = checkpoint;
23979
24990
  this.absolutePath = resolveToCwd(relativePath, cwd);
23980
- this.targetDir = dirname(this.absolutePath);
24991
+ this.targetDir = dirname2(this.absolutePath);
23981
24992
  }
23982
24993
  cwd;
23983
24994
  relativePath;
23984
24995
  fileContent;
23985
24996
  operations;
23986
24997
  createBackup;
24998
+ readState;
24999
+ checkpoint;
23987
25000
  absolutePath;
23988
25001
  targetDir;
25002
+ /**
25003
+ * Enforce the read-before-edit + staleness gate ahead of an OVERWRITE.
25004
+ * Brand-new files (those not yet on disk) are exempt — there is nothing the
25005
+ * model could have clobbered. No-op without a handle.
25006
+ */
25007
+ enforceGate() {
25008
+ if (!this.readState) return;
25009
+ if (!existsSync6(this.absolutePath)) return;
25010
+ const gate = enforceReadGate(this.readState, this.absolutePath);
25011
+ if (!gate.ok) {
25012
+ throw new Error(gate.message);
25013
+ }
25014
+ }
25015
+ /**
25016
+ * Snapshot the file's pre-mutation on-disk content for rewind. Runs AFTER the
25017
+ * read-state gate and BEFORE any mutation (mkdir/write), so the captured bytes
25018
+ * are the OLD content; a brand-new file is recorded as `null`. No-op without a
25019
+ * handle.
25020
+ */
25021
+ recordCheckpoint() {
25022
+ recordCheckpoint(this.checkpoint, this.absolutePath);
25023
+ }
23989
25024
  async prepareDirectories() {
23990
25025
  await this.operations.mkdir(this.targetDir);
23991
25026
  }
@@ -24000,6 +25035,7 @@ var WriteSession = class {
24000
25035
  }
24001
25036
  async persistContent() {
24002
25037
  await this.operations.writeFile(this.absolutePath, this.fileContent);
25038
+ recordReadState(this.readState, this.absolutePath);
24003
25039
  }
24004
25040
  buildResponse() {
24005
25041
  return {
@@ -24064,8 +25100,20 @@ function createWriteTool(cwd, options) {
24064
25100
  parameters: writeSchema,
24065
25101
  execute: async (_toolCallId, { path: path9, content }, signal) => {
24066
25102
  validator.validate(path9);
24067
- const writeSession = new WriteSession(cwd, path9, content, operations, Boolean(options?.createBackup));
25103
+ const writeSession = new WriteSession(
25104
+ cwd,
25105
+ path9,
25106
+ content,
25107
+ operations,
25108
+ Boolean(options?.createBackup),
25109
+ options?.readState,
25110
+ options?.checkpoint
25111
+ );
24068
25112
  return withAbort3(signal, async (isAborted) => {
25113
+ writeSession.enforceGate();
25114
+ throwIfAborted(isAborted);
25115
+ writeSession.recordCheckpoint();
25116
+ throwIfAborted(isAborted);
24069
25117
  await writeSession.prepareDirectories();
24070
25118
  throwIfAborted(isAborted);
24071
25119
  await writeSession.backupIfEnabled();
@@ -24689,13 +25737,13 @@ async function gcStaleTeamDirs(opts) {
24689
25737
  continue;
24690
25738
  }
24691
25739
  const teamDir = path4.join(teamsRootAbs, teamId);
24692
- let stat2;
25740
+ let stat3;
24693
25741
  try {
24694
- stat2 = await fs2.promises.stat(teamDir);
25742
+ stat3 = await fs2.promises.stat(teamDir);
24695
25743
  } catch {
24696
25744
  continue;
24697
25745
  }
24698
- if (!stat2.isDirectory()) continue;
25746
+ if (!stat3.isDirectory()) continue;
24699
25747
  let ageMs;
24700
25748
  try {
24701
25749
  const configPath = path4.join(teamDir, "config.json");
@@ -24704,12 +25752,12 @@ async function gcStaleTeamDirs(opts) {
24704
25752
  const createdAt = typeof config === "object" && config !== null && "createdAt" in config ? config.createdAt : void 0;
24705
25753
  if (typeof createdAt === "string") {
24706
25754
  const ts = Date.parse(createdAt);
24707
- ageMs = Number.isFinite(ts) ? now - ts : now - stat2.mtimeMs;
25755
+ ageMs = Number.isFinite(ts) ? now - ts : now - stat3.mtimeMs;
24708
25756
  } else {
24709
- ageMs = now - stat2.mtimeMs;
25757
+ ageMs = now - stat3.mtimeMs;
24710
25758
  }
24711
25759
  } catch {
24712
- ageMs = now - stat2.mtimeMs;
25760
+ ageMs = now - stat3.mtimeMs;
24713
25761
  }
24714
25762
  if (ageMs < maxAgeMs) {
24715
25763
  skipped.push({ teamId, reason: "too recent" });
@@ -26352,17 +27400,17 @@ import { homedir as homedir2 } from "os";
26352
27400
  import {
26353
27401
  appendFileSync as appendFileSync2,
26354
27402
  closeSync as closeSync2,
26355
- existsSync as existsSync7,
27403
+ existsSync as existsSync8,
26356
27404
  mkdirSync as mkdirSync2,
26357
27405
  openSync as openSync2,
26358
- readdirSync as readdirSync2,
26359
- readFileSync as readFileSync3,
27406
+ readdirSync as readdirSync5,
27407
+ readFileSync as readFileSync4,
26360
27408
  readSync,
26361
- statSync as statSync5,
27409
+ statSync as statSync8,
26362
27410
  writeFileSync as writeFileSync2
26363
27411
  } from "fs";
26364
- import { readdir, readFile as readFile2, stat } from "fs/promises";
26365
- import { join as join9, resolve as resolve4 } from "path";
27412
+ import { readdir, readFile as readFile2, stat as stat2 } from "fs/promises";
27413
+ import { join as join10, resolve as resolve4 } from "path";
26366
27414
  var CURRENT_SESSION_VERSION = 3;
26367
27415
  function generateId(byId) {
26368
27416
  let attempt = 0;
@@ -26542,11 +27590,11 @@ function getDefaultAgentDir() {
26542
27590
  return expandHomePath(explicitAgentDir);
26543
27591
  }
26544
27592
  const rawBase = process.env[ENV_BASE_DIR];
26545
- const baseDir = rawBase ? expandHomePath(rawBase) : join9(homedir2(), DEFAULT_CONFIG_DIR);
26546
- return join9(baseDir, "agent");
27593
+ const baseDir = rawBase ? expandHomePath(rawBase) : join10(homedir2(), DEFAULT_CONFIG_DIR);
27594
+ return join10(baseDir, "agent");
26547
27595
  }
26548
27596
  function getSessionsDir() {
26549
- return join9(getDefaultAgentDir(), "sessions");
27597
+ return join10(getDefaultAgentDir(), "sessions");
26550
27598
  }
26551
27599
  function encodeCwdForDir(cwd) {
26552
27600
  const trimmedLeadingSlash = cwd.replace(/^[/\\]/, "");
@@ -26554,8 +27602,8 @@ function encodeCwdForDir(cwd) {
26554
27602
  return `--${sanitized}--`;
26555
27603
  }
26556
27604
  function getDefaultSessionDir(cwd) {
26557
- const sessionDir = join9(getDefaultAgentDir(), "sessions", encodeCwdForDir(cwd));
26558
- if (!existsSync7(sessionDir)) {
27605
+ const sessionDir = join10(getDefaultAgentDir(), "sessions", encodeCwdForDir(cwd));
27606
+ if (!existsSync8(sessionDir)) {
26559
27607
  mkdirSync2(sessionDir, { recursive: true });
26560
27608
  }
26561
27609
  return sessionDir;
@@ -26565,10 +27613,10 @@ function hasValidSessionHeader(entries) {
26565
27613
  return first !== void 0 && first.type === "session" && typeof first.id === "string";
26566
27614
  }
26567
27615
  function loadEntriesFromFile(filePath) {
26568
- if (!existsSync7(filePath)) {
27616
+ if (!existsSync8(filePath)) {
26569
27617
  return [];
26570
27618
  }
26571
- const entries = decodeJsonlLines(readFileSync3(filePath, "utf8"));
27619
+ const entries = decodeJsonlLines(readFileSync4(filePath, "utf8"));
26572
27620
  if (entries.length === 0) {
26573
27621
  return entries;
26574
27622
  }
@@ -26596,7 +27644,7 @@ function isValidSessionFile(filePath) {
26596
27644
  }
26597
27645
  function findMostRecentSession(sessionDir) {
26598
27646
  try {
26599
- const ranked = readdirSync2(sessionDir).filter((f) => f.endsWith(".jsonl")).map((f) => join9(sessionDir, f)).filter(isValidSessionFile).map((path9) => ({ path: path9, mtime: statSync5(path9).mtime })).sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
27647
+ const ranked = readdirSync5(sessionDir).filter((f) => f.endsWith(".jsonl")).map((f) => join10(sessionDir, f)).filter(isValidSessionFile).map((path9) => ({ path: path9, mtime: statSync8(path9).mtime })).sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
26600
27648
  return ranked[0]?.path || null;
26601
27649
  } catch {
26602
27650
  return null;
@@ -26665,7 +27713,7 @@ async function buildSessionInfo(filePath) {
26665
27713
  return null;
26666
27714
  }
26667
27715
  const sessionHeader = header;
26668
- const stats = await stat(filePath);
27716
+ const stats = await stat2(filePath);
26669
27717
  let messageCount = 0;
26670
27718
  let firstMessage = "";
26671
27719
  let name;
@@ -26710,11 +27758,11 @@ async function buildSessionInfo(filePath) {
26710
27758
  }
26711
27759
  }
26712
27760
  async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal) {
26713
- if (!existsSync7(dir)) {
27761
+ if (!existsSync8(dir)) {
26714
27762
  return [];
26715
27763
  }
26716
27764
  try {
26717
- const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).map((f) => join9(dir, f));
27765
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).map((f) => join10(dir, f));
26718
27766
  const total = progressTotal ?? files.length;
26719
27767
  let loaded = 0;
26720
27768
  const results = await Promise.all(
@@ -26745,7 +27793,7 @@ var SessionManager = class _SessionManager {
26745
27793
  this.cwd = cwd;
26746
27794
  this.sessionDir = sessionDir;
26747
27795
  this.persist = persist;
26748
- if (persist && sessionDir && !existsSync7(sessionDir)) {
27796
+ if (persist && sessionDir && !existsSync8(sessionDir)) {
26749
27797
  mkdirSync2(sessionDir, { recursive: true });
26750
27798
  }
26751
27799
  if (sessionFile) {
@@ -26758,7 +27806,7 @@ var SessionManager = class _SessionManager {
26758
27806
  setSessionFile(sessionFile) {
26759
27807
  const resolved = resolve4(sessionFile);
26760
27808
  this.sessionFile = resolved;
26761
- if (!existsSync7(resolved)) {
27809
+ if (!existsSync8(resolved)) {
26762
27810
  this.newSession();
26763
27811
  this.sessionFile = resolved;
26764
27812
  return;
@@ -26797,7 +27845,7 @@ var SessionManager = class _SessionManager {
26797
27845
  this.flushed = false;
26798
27846
  if (this.persist) {
26799
27847
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
26800
- this.sessionFile = join9(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
27848
+ this.sessionFile = join10(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
26801
27849
  }
26802
27850
  return this.sessionFile;
26803
27851
  }
@@ -27153,7 +28201,7 @@ var SessionManager = class _SessionManager {
27153
28201
  const newSessionId = randomUUID();
27154
28202
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
27155
28203
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
27156
- const newSessionFile = join9(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
28204
+ const newSessionFile = join10(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
27157
28205
  const header = {
27158
28206
  type: "session",
27159
28207
  version: CURRENT_SESSION_VERSION,
@@ -27270,13 +28318,13 @@ var SessionManager = class _SessionManager {
27270
28318
  throw new Error(`Fork aborted \u2014 source session ${sourcePath} is missing its header record`);
27271
28319
  }
27272
28320
  const dir = sessionDir ?? getDefaultSessionDir(targetCwd);
27273
- if (!existsSync7(dir)) {
28321
+ if (!existsSync8(dir)) {
27274
28322
  mkdirSync2(dir, { recursive: true });
27275
28323
  }
27276
28324
  const newSessionId = randomUUID();
27277
28325
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
27278
28326
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
27279
- const newSessionFile = join9(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
28327
+ const newSessionFile = join10(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
27280
28328
  const newHeader = {
27281
28329
  type: "session",
27282
28330
  version: CURRENT_SESSION_VERSION,
@@ -27314,16 +28362,16 @@ var SessionManager = class _SessionManager {
27314
28362
  static async listAll(onProgress) {
27315
28363
  const sessionsDir = getSessionsDir();
27316
28364
  try {
27317
- if (!existsSync7(sessionsDir)) {
28365
+ if (!existsSync8(sessionsDir)) {
27318
28366
  return [];
27319
28367
  }
27320
28368
  const rootEntries = await readdir(sessionsDir, { withFileTypes: true });
27321
- const dirs = rootEntries.filter((e) => e.isDirectory()).map((e) => join9(sessionsDir, e.name));
28369
+ const dirs = rootEntries.filter((e) => e.isDirectory()).map((e) => join10(sessionsDir, e.name));
27322
28370
  const perDirFiles = [];
27323
28371
  let totalFiles = 0;
27324
28372
  for (const dir of dirs) {
27325
28373
  try {
27326
- const jsonlFiles = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).map((f) => join9(dir, f));
28374
+ const jsonlFiles = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).map((f) => join10(dir, f));
27327
28375
  perDirFiles.push(jsonlFiles);
27328
28376
  totalFiles += jsonlFiles.length;
27329
28377
  } catch {
@@ -27361,11 +28409,14 @@ export {
27361
28409
  ComposioService,
27362
28410
  DEFAULT_MAX_BYTES,
27363
28411
  DEFAULT_MAX_LINES,
28412
+ DESANITIZATIONS,
28413
+ FILE_NOT_FOUND_CWD_NOTE,
27364
28414
  IndusagiComposioProvider,
27365
28415
  LIVE_STATUSES,
27366
28416
  MESSAGE_TYPE_PROCESS_UPDATE,
27367
28417
  PIRATE_NAME_POOL,
27368
28418
  ProcessManager,
28419
+ READ_ONLY_TOOL_NAMES,
27369
28420
  SessionManager,
27370
28421
  TEAM_ATTACH_CLAIM_FILE,
27371
28422
  TEAM_ATTACH_CLAIM_STALE_MS,
@@ -27384,10 +28435,15 @@ export {
27384
28435
  agentLoop,
27385
28436
  agentLoopContinue,
27386
28437
  allTools,
28438
+ argvToCommandString,
27387
28439
  assertTeamDirWithinTeamsRoot,
27388
28440
  assessAttachClaimFreshness,
27389
28441
  bashExecutionToText,
27390
28442
  bashTool,
28443
+ buildBwrapArgv,
28444
+ buildSandboxArgv,
28445
+ buildSeatbeltArgv,
28446
+ buildSeatbeltProfile,
27391
28447
  buildSessionContext,
27392
28448
  claimNextAvailableTask,
27393
28449
  claimTask,
@@ -27424,6 +28480,7 @@ export {
27424
28480
  createProcessTool,
27425
28481
  createReadOnlyTools,
27426
28482
  createReadTool,
28483
+ createSandboxedBashOperations,
27427
28484
  createTask,
27428
28485
  createTodoReadTool,
27429
28486
  createTodoWriteTool,
@@ -27431,11 +28488,13 @@ export {
27431
28488
  createWebFetchTool,
27432
28489
  createWebSearchTool,
27433
28490
  createWriteTool,
28491
+ desanitizeMatchString,
27434
28492
  editTool,
27435
28493
  ensureTeamConfig,
27436
28494
  ensureWorktreeCwd,
27437
28495
  expandPath,
27438
28496
  findMostRecentSession,
28497
+ findSimilarFile,
27439
28498
  findTool,
27440
28499
  formatProviderModel,
27441
28500
  formatSize,
@@ -27477,21 +28536,25 @@ export {
27477
28536
  pickNamesFromPool,
27478
28537
  pickPirateNames,
27479
28538
  popUnreadMessages,
28539
+ preserveQuoteStyle,
27480
28540
  processTool,
27481
28541
  readOnlyTools,
27482
28542
  readTeamAttachClaim,
27483
28543
  readTool,
27484
28544
  releaseTeamAttachClaim,
27485
28545
  removeTaskDependency,
28546
+ replaceAllLiteral,
27486
28547
  resolveReadPath,
27487
28548
  resolveTeammateModelSelection,
27488
28549
  resolveToCwd,
28550
+ sandboxAvailability,
27489
28551
  sanitizeName,
27490
28552
  setMemberStatus,
27491
28553
  setTeamStyle,
27492
28554
  shortTaskId,
27493
28555
  startAssignedTask,
27494
28556
  streamProxy,
28557
+ suggestPathUnderCwd,
27495
28558
  taskAssignmentPayload,
27496
28559
  todoReadTool,
27497
28560
  todoWriteTool,