//! Tool surface dispatched by the agent loop.
//!
//! Phase 6.2 adds `write_file` and `edit_file` on top of the 6.0
//! read-only set (`read_file`, `list_dir`, `grep`, `bash`). The
//! agent can now create new files, overwrite existing ones, and do
//! surgical exact-string edits without going through `bash`. IDE-
//! native tools (`goto_definition`, `git_*`) and container-aware
//! `bash` (via `WorkspaceHost::spawn`) land in later sub-phases as
//! concrete need appears — see `specs/coder.md` § Tool surface.
//!
//! Every tool dispatches against the active workspace folder via
//! [`moon_core::WorkspaceHost`] (or a service that takes its root,
//! such as `moon_core::search`). That gives us container-aware
//! routing for free once Phase 2 grows the [`WorkspaceHost`] impl
//! for `ContainerHost` *and* `WorkspaceHost::spawn` exists.
//!
//! Per `specs/coder.md` § Error model: tools **throw**. Returning a
//! string like "ERROR: ..." as content confuses the model. Errors
//! become `isError: true` content blocks at the loop layer.

use std::collections::HashSet;
use std::io::{Read, Seek};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use camino::{Utf8Path, Utf8PathBuf};
use moon_container::{Workspace as ContainerWorkspace, WorkspaceConfig};
use moon_core::{WorkspaceFolderEntry, WorkspaceRegistry};
use moon_protocol::container::ContainerState;
use moon_protocol::fs::{DirEntry, EntryKind, ReadFileResult, WriteFileResult};
use moon_protocol::search::{ContentSearchHit, ContentSearchOptions};
use moon_terminal::{
	container_name_for_workspace, TerminalRegistry, TerminalTarget, DEFAULT_READ_LINES, MAX_READ_LINES,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio_util::sync::CancellationToken;

use crate::error::CoderError;
use crate::inference::ToolDefinition;
use crate::mcp::{McpManager, McpMount, McpRoot, McpSpawnKind, McpSpawnTarget};
use crate::web::WebClient;

/// Hard cap on `bash` runtime — keeps a runaway tool call from
/// burning the LLM's budget waiting for a hung process. Matches the
/// "single bash per call" pi convention. The agent can chain bash
/// tool calls if it really wants to wait longer.
const BASH_DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const BASH_MAX_TIMEOUT: Duration = Duration::from_secs(600);

/// `read_file` returns at most this many bytes. Beyond it we
/// truncate + tell the model so it can iterate (e.g. follow up with
/// `grep` for the part it cares about). Full-file reads are still
/// useful — most source files fit comfortably.
const READ_FILE_MAX_BYTES: usize = 200_000;

/// Largest image `read_file` will attach for the model. Vision
/// providers downscale aggressively past a few megapixels anyway,
/// so past this we refuse rather than burn the context window on
/// diminishing returns.
///
/// Also kept under the wire budget's ceiling (ADR 0049) on purpose:
/// at 3 MB a re-encoded attachment lands around 2.3 MB of base64, so
/// no single image can exceed the budget by itself. Refusing with a
/// message the model can act on beats attaching something the
/// budget then has to drop behind its back.
const IMAGE_MAX_BYTES: usize = 3_000_000;

/// `bash` stdout/stderr cap. Same rationale as `READ_FILE_MAX_BYTES`
/// — the model doesn't need megabytes of output to reason about a
/// command's outcome.
const BASH_OUTPUT_MAX_BYTES: usize = 64_000;

/// Two flavours every dispatched tool runs under. The parent's
/// top-level turn always uses [`CoderMode::Agent`]; sub-agents
/// pick per spawn (Phase C of the multi-project plan). Surfaced
/// to tools via [`ToolContext`] so write-side tools can self-gate
/// without each one re-deriving the rule.
///
/// The variant name `Agent` (and its wire string `"agent"`) is
/// deliberate: an earlier iteration called this `Coder`, but the
/// model would consistently treat a `mode: "coder"` sub-agent as
/// less capable than itself and hesitate to delegate non-trivial
/// work. `agent` reads as "another instance of you" in the model's
/// vocabulary and lifted that hesitation in dogfooding.
///
/// `Coordinator` is a top-level mode (ADR 0030) for orchestrator
/// sessions: read-only like `Research` (`allows_writes` is false) but
/// advertises a different tool list — `spawn_worker` and the worker-
/// management tools instead of `task` / `ask_user` — and runs under
/// a coordinator-specific system prompt. Top-level sessions pick the
/// mode on creation; sub-agents stay `Research` / `Agent` only.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoderMode {
	/// Read-only intent. `read_file` / `list_dir` / `grep` / `bash`
	/// stay available; `write_file` / `edit_file` short-circuit
	/// with [`CoderError::ReadOnlyMode`]. The "no mutation via
	/// `bash`" half of the constraint is behavioural — we can't
	/// usefully sandbox a shell — and lives in the sub-agent's
	/// system prompt instead.
	Research,
	/// Full toolkit. Today's parent-turn behaviour.
	Agent,
	/// Top-level orchestrator mode (ADR 0030). Read-only like
	/// `Research` (the coordinator must delegate mutation to its
	/// workers) but with the worker-management tools in place of
	/// `task` / `ask_user`. Top-level only — sub-agents never run
	/// in this mode, so the `task` tool's JSON-schema `mode` enum
	/// stays `["research", "agent"]`.
	Coordinator,
}

impl CoderMode {
	pub fn allows_writes(self) -> bool {
		matches!(self, Self::Agent)
	}

	/// Whether this mode advertises the sub-agent delegation tool
	/// (`task`) to the model. Sub-agents don't (depth-1 cap); a
	/// top-level coordinator doesn't either — it spawns peer
	/// sessions via `spawn_worker`, not sub-agents via `task`.
	/// Only the ordinary top-level `Agent` mode does.
	pub fn allows_task_tool(self) -> bool {
		matches!(self, Self::Agent)
	}

	/// Whether this mode advertises `ask_user`. Sub-agents don't
	/// (no panel to answer through). A top-level coordinator
	/// doesn't either (ADR 0030 §Fork 1 — the coordinator's
	/// `ask_user`-equivalent is `respond_to_worker_prompt`, and
	/// human questions route to the user via the worker's own
	/// `ask_user`). Only the ordinary top-level `Agent` mode does.
	pub fn allows_ask_user(self) -> bool {
		matches!(self, Self::Agent)
	}

	/// Wire string used by event payloads (`SubagentSpawned.mode`,
	/// the `mode` field on the `task` tool result, the top-level
	/// session header's `mode` field). Stable identifiers —
	/// `"research"` / `"agent"` / `"coordinator"` — that the
	/// frontend reads verbatim, so don't rename without also
	/// updating `src/lib/protocol.ts`.
	pub fn as_wire(self) -> &'static str {
		match self {
			Self::Research => "research",
			Self::Agent => "agent",
			Self::Coordinator => "coordinator",
		}
	}

	/// Parse a wire string back into a `CoderMode`. Accepts the
	/// `as_wire` outputs; an unknown string errors with the valid
	/// values named. Used for the top-level session header's `mode`
	/// field on load — `None` / absent defaults to `Agent` (every
	/// pre-coordinator session is an ordinary agent session).
	pub fn from_wire(s: &str) -> Result<Self, CoderError> {
		match s {
			"research" => Ok(Self::Research),
			"agent" => Ok(Self::Agent),
			"coordinator" => Ok(Self::Coordinator),
			other => Err(CoderError::invalid_args(
				"mode",
				format!("`{other}` is not a valid mode; expected `research`, `agent`, or `coordinator`"),
			)),
		}
	}

	/// Parse an optional wire string for the top-level session
	/// header's `mode` field. `None` / absent → `Agent` (the
	/// pre-coordinator default, byte-compatible with every existing
	/// session on disk).
	pub fn from_top_level_wire(s: Option<&str>) -> Self {
		match s {
			None | Some("agent") => Self::Agent,
			Some("coordinator") => Self::Coordinator,
			// `research` is a sub-agent-only mode; a top-level
			// session carrying it (only via a hand-edited header)
			// falls back to `Agent` rather than honouring a state
			// that `run_turn` never sets. Logged by the caller.
			Some("research") => Self::Agent,
			Some(other) => {
				tracing::warn!(mode = other, "unknown top-level session mode; falling back to agent");
				Self::Agent
			}
		}
	}
}

/// Per-dispatch context. Replaces the previous "every tool calls
/// `workspaces.active_folder()` itself" pattern: the dispatcher
/// resolves the folder + mode once and hands them to each tool
/// invocation. Lets the sub-agent runner (Phase C) point a
/// concurrent dispatch at a different folder + mode pair without
/// touching the global active-folder state.
///
/// Also carries a turn-scoped [`FormatQueue`] that `write_file` /
/// `edit_file` push into instead of running the format-on-save
/// pipeline inline. The runner drains the queue at turn end (see
/// `run_turn` / `run_subagent`). One queue per turn, shared across
/// every tool dispatch in that turn — parent and sub-agent each
/// own their own.
#[derive(Clone)]
pub struct ToolContext {
	pub folder: Arc<WorkspaceFolderEntry>,
	pub mode: CoderMode,
	pub format_queue: Arc<FormatQueue>,
	/// Per-turn registry of detached background processes (ADR 0034).
	/// `bash` with `detach: true` spawns into here; `read_process`
	/// and `stop_process` read/kill from here; the runner calls
	/// `cleanup()` at turn end.
	pub background: Arc<BackgroundProcessRegistry>,
	/// When true, the `bash` tool routes to the host machine even
	/// if the workspace shell container is running — the per-session
	/// [`BashTargetOverride::ForceHost`](crate::sessions::BashTargetOverride)
	/// escape hatch. Shared with the owning `SessionRuntime` (the
	/// runner clones the session's live flag in at turn spawn), so a
	/// toggle mid-turn re-routes the *next* tool dispatch instead of
	/// waiting for a fresh turn — a long-running agent doesn't force
	/// the user to sit out the rest of the turn on the wrong target.
	/// An in-flight command keeps the target it started with.
	force_host_bash: Arc<std::sync::atomic::AtomicBool>,
}

impl ToolContext {
	pub fn new(folder: Arc<WorkspaceFolderEntry>, mode: CoderMode) -> Self {
		Self {
			folder,
			mode,
			format_queue: Arc::new(FormatQueue::default()),
			background: Arc::new(BackgroundProcessRegistry::default()),
			force_host_bash: Arc::new(std::sync::atomic::AtomicBool::new(false)),
		}
	}

	/// Like [`new`](Self::new) but reuses an existing queue. Used
	/// when one logical turn fans out into multiple `ToolContext`s
	/// (e.g. the parent's homogeneous-`task` parallel-dispatch
	/// path) and we still want a single flush at the end.
	pub fn with_format_queue(folder: Arc<WorkspaceFolderEntry>, mode: CoderMode, queue: Arc<FormatQueue>) -> Self {
		Self {
			folder,
			mode,
			format_queue: queue,
			background: Arc::new(BackgroundProcessRegistry::default()),
			force_host_bash: Arc::new(std::sync::atomic::AtomicBool::new(false)),
		}
	}

	/// Builder-style setter for the per-session force-host-bash
	/// flag — the session's **live** flag, shared so a mid-turn
	/// toggle applies to the next tool dispatch. Kept separate from
	/// the constructors so call sites that always run auto don't
	/// have to thread it.
	pub fn with_force_host_bash(mut self, force_host: Arc<std::sync::atomic::AtomicBool>) -> Self {
		self.force_host_bash = force_host;
		self
	}

	/// Current value of the per-session force-host-bash flag.
	pub fn force_host_bash(&self) -> bool {
		self.force_host_bash.load(std::sync::atomic::Ordering::Relaxed)
	}

	/// The shared live flag itself — for handing down to a
	/// sub-agent's `ToolContext` so it follows the same toggle.
	pub fn force_host_bash_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
		self.force_host_bash.clone()
	}

	/// Builder-style setter for the per-turn background-process
	/// registry (ADR 0034). The runner creates one `Arc` and shares
	/// it across the turn so `bash` (spawn), `read_process` (poll),
	/// `stop_process` (kill), and the turn-end `cleanup()` all reach
	/// the same registry.
	pub fn with_background(mut self, background: Arc<BackgroundProcessRegistry>) -> Self {
		self.background = background;
		self
	}
}

/// Where a path argument resolved to. Produced by
/// [`ToolRegistry::resolve_target`] and consumed by the four
/// filesystem tools (`read_file` / `list_dir` / `write_file` /
/// `edit_file`) so they can branch between the bound-folder host
/// (which keeps format-on-save and the future in-container
/// `RemoteHost`) and the container-aware out-of-workspace
/// primitives.
enum ResolvedTarget {
	/// Path lands inside a bound folder. `relative` is the
	/// portion inside the folder root (`.` for the root itself).
	InWorkspace {
		folder: Arc<WorkspaceFolderEntry>,
		relative: String,
	},
	/// Absolute path outside every bound folder. Routed through
	/// `docker exec` when the workspace shell container is running,
	/// the host filesystem otherwise.
	OutOfWorkspace { abs_path: Utf8PathBuf },
}

impl ResolvedTarget {
	fn in_workspace(folder: Arc<WorkspaceFolderEntry>, relative: String) -> Self {
		Self::InWorkspace { folder, relative }
	}

	fn out_of_workspace(abs_path: Utf8PathBuf) -> Self {
		Self::OutOfWorkspace { abs_path }
	}
}

/// Per-turn set of files the coder's write/edit tools touched.
///
/// `write_file` / `edit_file` write raw bytes through
/// [`WorkspaceHost::write_file`] and register the touched path
/// here; at turn end the runner drains the queue and runs
/// [`WorkspaceHost::format_file`] against each entry exactly once,
/// regardless of how many times the model edited that file. See
/// the ADR superseding 0013's per-tool-call invocation for why.
#[derive(Default)]
pub struct FormatQueue {
	entries: Mutex<HashSet<FormatQueueEntry>>,
}

#[derive(Clone, Eq, Hash, PartialEq)]
struct FormatQueueEntry {
	folder_path: String,
	relative_path: Utf8PathBuf,
}

impl FormatQueue {
	/// Record that `(folder, relative_path)` was written this turn.
	/// Idempotent; the same path coming through N times still
	/// flushes once.
	pub fn record(&self, folder: &WorkspaceFolderEntry, relative_path: &Utf8Path) {
		let entry = FormatQueueEntry {
			folder_path: folder.folder.path.clone(),
			relative_path: relative_path.to_path_buf(),
		};
		// `Mutex::lock` only fails on poisoning; treat that as a
		// programmer error (some earlier panic inside a tool
		// holding the guard) and ignore — the queue is a hint,
		// not authoritative state.
		let Ok(mut guard) = self.entries.lock() else {
			return;
		};
		guard.insert(entry);
	}

	/// Drain the queue and return the recorded entries grouped for
	/// the caller to dispatch against. The set is cleared so a
	/// subsequent flush call is a no-op.
	pub fn drain(&self) -> Vec<(String, Utf8PathBuf)> {
		let Ok(mut guard) = self.entries.lock() else {
			return Vec::new();
		};
		guard.drain().map(|e| (e.folder_path, e.relative_path)).collect()
	}

	/// `true` when no tool has registered a path yet. Used by the
	/// runner to skip the flush log entirely on read-only turns.
	pub fn is_empty(&self) -> bool {
		self.entries.lock().map(|g| g.is_empty()).unwrap_or(true)
	}
}

/// Per-turn registry of detached background processes spawned by
/// `bash` with `detach: true` (ADR 0034). Each entry holds the child
/// handle and the host-side log file path; `read_process` tails the
/// log and `stop_process` kills + reaps. At turn end
/// [`BackgroundProcessRegistry::cleanup`] kills every still-running
/// process and deletes the log files — for every termination
/// (Ok / Aborted / Err), mirroring `FormatQueue::drain`.
///
/// Scoped to the turn (same lifetime as `FormatQueue`): the model is
/// expected to poll `read_process` until completion before finishing
/// its answer. Processes that are still running when the turn ends
/// are killed as a safety net.
#[derive(Default)]
pub struct BackgroundProcessRegistry {
	entries: Mutex<Vec<BackgroundProcess>>,
}

struct BackgroundProcess {
	/// Opaque id returned to the model (e.g. `"bg_0"`, `"bg_1"`).
	id: String,
	/// The original command string — echoed back in `read_process`
	/// results so the model doesn't have to track it separately.
	cmd: String,
	/// Where the command ran — `"host"` or `"container"`, same
	/// vocabulary as the `bash` tool's `target` field.
	target: &'static str,
	/// Host-side log file collecting the process's stdout + stderr.
	log_path: PathBuf,
	/// The child handle. `None` once the process has been reaped
	/// (either naturally, via `stop_process`, or via `cleanup`).
	child: Option<tokio::process::Child>,
	/// Exit code captured when the process was reaped, so subsequent
	/// `read_process` calls after completion can still report it.
	exit_code: Option<i32>,
}

impl BackgroundProcessRegistry {
	/// Allocate the next monotonic id for a background process.
	/// Uses the current entry count so ids are unique within a turn
	/// (`bg_0`, `bg_1`, …). Concurrent calls are serialized by the
	/// mutex inside `spawn`.
	fn next_id(&self) -> String {
		let count = self.entries.lock().map(|g| g.len()).unwrap_or(0);
		format!("bg_{count}")
	}

	/// Spawn a detached process: the child's stdout+stderr are
	/// redirected to a host-side log file, the handle is stored, and
	/// the opaque id + log path are returned for the tool result.
	/// The process runs until it exits, `stop_process` kills it, or
	/// `cleanup` reaps it at turn end.
	fn spawn(
		&self,
		id: String,
		mut command: tokio::process::Command,
		cmd: String,
		target: &'static str,
	) -> Result<(String, PathBuf, Option<u32>), CoderError> {
		let log_path = background_log_path(&id);
		if let Some(parent) = log_path.parent() {
			std::fs::create_dir_all(parent)
				.map_err(|err| CoderError::tool_failed("bash", format!("failed to create log dir: {err}")))?;
		}
		let log_file = std::fs::File::create(&log_path)
			.map_err(|err| CoderError::tool_failed("bash", format!("failed to create log file: {err}")))?;
		let stdout = std::process::Stdio::from(
			log_file
				.try_clone()
				.map_err(|err| CoderError::tool_failed("bash", format!("failed to clone log file: {err}")))?,
		);
		let stderr = std::process::Stdio::from(log_file);
		command
			.stdin(std::process::Stdio::null())
			.stdout(stdout)
			.stderr(stderr)
			.kill_on_drop(true);
		let child = command
			.spawn()
			.map_err(|err| CoderError::tool_failed("bash", format!("spawn failed: {err}")))?;
		let pid = child.id();
		let Ok(mut guard) = self.entries.lock() else {
			return Err(CoderError::tool_failed("bash", "registry lock poisoned"));
		};
		guard.push(BackgroundProcess {
			id: id.clone(),
			cmd,
			target,
			log_path: log_path.clone(),
			child: Some(child),
			exit_code: None,
		});
		Ok((id, log_path, pid))
	}

	/// Poll a detached process: optionally wait up to `wait_ms` for
	/// it to exit, then return its running status, exit code (if
	/// finished), and the tail of its log file.
	async fn read(
		&self,
		id: &str,
		wait_ms: u64,
		tail_bytes: usize,
		cancel: &CancellationToken,
	) -> Result<Value, CoderError> {
		// Scope the mutex guard so we drop it before any await —
		// `try_wait` and `tokio::time::sleep` would otherwise hold
		// it across a yield point.
		let snapshot = {
			let Ok(guard) = self.entries.lock() else {
				return Err(CoderError::tool_failed("read_process", "registry lock poisoned"));
			};
			guard
				.iter()
				.find(|p| p.id == id)
				.map(|p| (p.cmd.clone(), p.target, p.log_path.clone()))
		};
		let Some((cmd, target, log_path)) = snapshot else {
			return Err(CoderError::tool_failed(
				"read_process",
				format!("no background process with id '{id}'"),
			));
		};

		// If a wait was requested, poll until exit or timeout.
		if wait_ms > 0 {
			let deadline = std::time::Instant::now() + Duration::from_millis(wait_ms);
			loop {
				if self.try_reap(id) {
					break;
				}
				if cancel.is_cancelled() {
					return Err(CoderError::Aborted);
				}
				if std::time::Instant::now() >= deadline {
					break;
				}
				tokio::select! {
					biased;
					_ = cancel.cancelled() => return Err(CoderError::Aborted),
					_ = tokio::time::sleep(Duration::from_millis(BG_POLL_INTERVAL_MS)) => {}
				}
			}
		}

		let (running, exit_code) = self.status(id);
		let tail = read_tail(&log_path, tail_bytes);
		Ok(json!({
			"id": id,
			"running": running,
			"exit_code": exit_code,
			"tail": tail,
			"cmd": cmd,
			"target": target,
		}))
	}

	/// Kill a running process if it hasn't exited yet, then reap it.
	/// Returns the exit code (0 if we killed it, the natural code if
	/// it had already finished). No-op if the id doesn't exist or the
	/// process was already reaped.
	async fn stop(&self, id: &str) -> Result<Value, CoderError> {
		// Take the child out of the entry under the lock, then drop
		// the lock before awaiting `wait()` — `tokio::process::Child`
		// is `Send` but `std::sync::MutexGuard` is not, and the guard
		// can't span the await.
		let (cmd, target, mut child) = {
			let Ok(mut guard) = self.entries.lock() else {
				return Err(CoderError::tool_failed("stop_process", "registry lock poisoned"));
			};
			let Some(proc) = guard.iter_mut().find(|p| p.id == id) else {
				return Err(CoderError::tool_failed(
					"stop_process",
					format!("no background process with id '{id}'"),
				));
			};
			(proc.cmd.clone(), proc.target, proc.child.take())
		};

		let killed;
		let exit_code;
		if let Some(child) = &mut child {
			// Best-effort kill — the process may have just exited.
			match child.try_wait() {
				Ok(Some(status)) => {
					// Already exited naturally.
					killed = false;
					exit_code = status.code();
				}
				_ => {
					// Still running (or try_wait errored) — kill it.
					let _ = child.start_kill();
					let status = tokio::time::timeout(Duration::from_secs(5), child.wait())
						.await
						.ok()
						.and_then(|r| r.ok());
					killed = true;
					exit_code = status.and_then(|s| s.code());
				}
			}
		} else {
			// Already reaped — retrieve the stored exit code.
			killed = false;
			exit_code = self.stored_exit_code(id);
		}

		// Store the exit code for future `read_process` calls.
		self.store_exit_code(id, exit_code);

		Ok(json!({
			"id": id,
			"killed": killed,
			"exit_code": exit_code,
			"cmd": cmd,
			"target": target,
		}))
	}

	/// Kill + reap every still-running process and delete log files.
	/// Called at turn end by the runner, for every termination path.
	pub async fn cleanup(&self) {
		let entries = {
			let Ok(mut guard) = self.entries.lock() else {
				return;
			};
			std::mem::take(&mut *guard)
		};
		for mut proc in entries {
			if let Some(child) = &mut proc.child {
				let _ = child.start_kill();
				// Reap with a short timeout so a stuck process
				// doesn't hang turn-end indefinitely.
				let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
			}
			let _ = std::fs::remove_file(&proc.log_path);
		}
	}

	/// Try to reap a process non-destructively. Returns `true` if the
	/// process has exited (and was reaped). Does not remove the entry
	/// — `read_process` still needs to report exit_code + tail after
	/// the process finishes.
	fn try_reap(&self, id: &str) -> bool {
		let Ok(mut guard) = self.entries.lock() else {
			return false;
		};
		let Some(proc) = guard.iter_mut().find(|p| p.id == id) else {
			return false;
		};
		if let Some(child) = &mut proc.child {
			match child.try_wait() {
				Ok(Some(status)) => {
					// Process exited — store the exit code and drop
					// the handle so we stop polling it. The entry
					// stays so `read_process` can still report.
					proc.exit_code = status.code();
					proc.child = None;
					return true;
				}
				Ok(None) => return false,
				Err(_) => return false,
			}
		}
		// Already reaped previously.
		true
	}

	/// Check whether a process is still running. Returns
	/// `(running, exit_code)`. Calls `try_wait` internally so this
	/// also reaps newly-exited processes.
	fn status(&self, id: &str) -> (bool, Option<i32>) {
		let Ok(mut guard) = self.entries.lock() else {
			return (false, None);
		};
		let Some(proc) = guard.iter_mut().find(|p| p.id == id) else {
			return (false, None);
		};
		if let Some(child) = &mut proc.child {
			match child.try_wait() {
				Ok(Some(status)) => {
					proc.exit_code = status.code();
					proc.child = None;
					(false, status.code())
				}
				Ok(None) => (true, None),
				Err(_) => (false, None),
			}
		} else {
			// Already reaped — return the stored exit code.
			(false, proc.exit_code)
		}
	}

	/// Read the stored exit code for a reaped process.
	fn stored_exit_code(&self, id: &str) -> Option<i32> {
		let Ok(guard) = self.entries.lock() else {
			return None;
		};
		guard.iter().find(|p| p.id == id).and_then(|p| p.exit_code)
	}

	/// Store the exit code for a process (after `stop_process` reaps
	/// it) so subsequent `read_process` calls can still report it.
	fn store_exit_code(&self, id: &str, code: Option<i32>) {
		let Ok(mut guard) = self.entries.lock() else {
			return;
		};
		if let Some(proc) = guard.iter_mut().find(|p| p.id == id) {
			proc.exit_code = code;
		}
	}
}

/// Poll interval for `read_process`'s `wait_ms` loop. Short enough
/// to feel responsive, long enough to not hammer the OS.
const BG_POLL_INTERVAL_MS: u64 = 200;

/// Default + cap for `read_process`'s `tail_bytes` parameter.
const BG_DEFAULT_TAIL_BYTES: usize = 8_000;
const BG_MAX_TAIL_BYTES: usize = 64_000;

/// Default + cap for `read_process`'s `wait_ms` parameter.
const BG_DEFAULT_WAIT_MS: u64 = 0;
const BG_MAX_WAIT_MS: u64 = 600_000;

/// Generate a host-side log file path for a background process.
fn background_log_path(id: &str) -> PathBuf {
	let mut path = std::env::temp_dir();
	path.push("moon-coder-bg");
	path.push(format!("{id}.log"));
	path
}

/// Read the last `max_bytes` of a file as a UTF-8 string. If the
/// file is smaller than `max_bytes`, returns the entire contents.
fn read_tail(path: &Path, max_bytes: usize) -> String {
	let Ok(metadata) = std::fs::metadata(path) else {
		return String::new();
	};
	let file_len = metadata.len() as usize;
	let Ok(mut file) = std::fs::File::open(path) else {
		return String::new();
	};
	let start = file_len.saturating_sub(max_bytes);
	if start > 0 {
		if let Err(err) = file.seek(std::io::SeekFrom::Start(start as u64)) {
			let _ = err;
			return String::new();
		}
	}
	let mut buf = Vec::with_capacity(max_bytes.min(file_len.saturating_sub(start)));
	let _ = file.read_to_end(&mut buf);
	// If we started mid-file, drop the first partial line so the
	// tail starts at a clean line boundary.
	if start > 0 && !buf.is_empty() {
		if let Some(idx) = buf.iter().position(|&b| b == b'\n') {
			buf = buf[idx + 1..].to_vec();
		}
	}
	let cut = clamp_to_char_boundary_bytes(&buf, buf.len());
	String::from_utf8_lossy(&buf[..cut]).into_owned()
}

/// Shared JSON shape for one terminal, used by both
/// `list_terminals` rows and the `read_terminal` result so the
/// model sees the same field names either way.
fn terminal_info_json(info: &moon_terminal::TerminalInfo) -> Value {
	json!({
		"id": info.id,
		"target": info.kind.as_str(),
		"cwd": info.cwd,
		"cols": info.cols,
		"rows": info.rows,
		"running": info.running,
		"exit_code": info.exit_code,
		"buffered_bytes": info.buffered_bytes,
	})
}

/// Tools are dispatched by name. The registry holds the JSON-schema
/// descriptors handed to the LLM, the workspace registry the runtime
/// needs to resolve container state for `bash`, the workspaces
/// state-dir parent so `bash` can ask `moon-container` whether the
/// workspace shell container is running, and the shared [`WebClient`]
/// the web search / fetch tools dispatch through. The per-call folder
/// + mode arrive via [`ToolContext`] on each [`dispatch`](Self::dispatch).
#[derive(Clone)]
pub struct ToolRegistry {
	workspaces: Arc<WorkspaceRegistry>,
	workspaces_dir: Utf8PathBuf,
	web: WebClient,
	mcp: Arc<McpManager>,
	/// The IDE's open terminal tabs, shared from the Tauri layer so
	/// `list_terminals` / `read_terminal` see exactly what the user
	/// sees (ADR 0048).
	terminals: Arc<TerminalRegistry>,
	/// Live model picks + catalog-derived capability caches. Tools
	/// consult this at dispatch time (not turn start) so a mid-turn
	/// model swap changes what the *next* image-producing tool call
	/// does — same freshness discipline as the inference client.
	models: crate::models::SharedCoderModels,
}

impl ToolRegistry {
	pub fn new(
		workspaces: Arc<WorkspaceRegistry>,
		workspaces_dir: Utf8PathBuf,
		web: WebClient,
		terminals: Arc<TerminalRegistry>,
		models: crate::models::SharedCoderModels,
	) -> Self {
		Self {
			workspaces,
			workspaces_dir,
			web,
			mcp: Arc::new(McpManager::default()),
			terminals,
			models,
		}
	}

	/// Whether the active standard model accepts image input —
	/// unknown counts as yes. Gates the image-producing tool
	/// paths (`read_file` on an image, MCP image blocks) so a
	/// text-only model gets an explanation instead of pixels it
	/// can't see (which the wire encoder would strip anyway).
	async fn images_supported(&self) -> bool {
		self.models.read().await.standard_supports_images().unwrap_or(true)
	}

	/// Shared [`McpManager`]. Exposed so the Tauri command layer
	/// can drop a server's connection when the user disables or
	/// removes it in settings.
	pub fn mcp(&self) -> &Arc<McpManager> {
		&self.mcp
	}

	/// Shared [`WebClient`]. Exposed so the Tauri command layer can
	/// expose the keyring-backed Tavily key surface (status / set /
	/// clear) without needing its own keyring entry.
	pub fn web(&self) -> &WebClient {
		&self.web
	}

	/// `true` when the workspace shell container is currently
	/// `Running` and the `bash` tool would route there. Reused by
	/// the system-prompt builder to decide whether to advertise
	/// folders by their host absolute paths (host mode) or under
	/// the synthetic `/workspace/<name>` mount the container
	/// actually exposes (container mode). Falls back to host on
	/// any docker-side failure, matching the bash tool's posture.
	///
	/// `force_host` is the per-session override: when set, this
	/// always reports `false` (host mode) so the system prompt
	/// advertises host paths consistently with where `bash` will
	/// actually run. `folder` is the session's bound folder — a
	/// folder the running container doesn't mount reports `false`
	/// too, matching the bash tool's host fallback.
	pub async fn bash_target_is_container(&self, force_host: bool, folder: &WorkspaceFolderEntry) -> bool {
		resolve_bash_target(&self.workspaces, &self.workspaces_dir, force_host, folder).await == BASH_TARGET_CONTAINER
	}

	/// Build a [`ToolContext`] from the workspace's current active
	/// folder. Callers that already know the folder (sub-agent
	/// runner) construct `ToolContext::new` directly; this helper
	/// is the convenience the parent's `run_turn` uses to keep its
	/// "active folder is the parent folder" invariant in one spot.
	pub async fn context_for_active(&self, mode: CoderMode) -> Result<ToolContext, CoderError> {
		let folder = self
			.workspaces
			.active_folder()
			.await
			.ok_or(CoderError::NoActiveFolder)?;
		Ok(ToolContext::new(folder, mode))
	}

	/// Resolve a path argument and route to the right bound folder.
	/// Returns the `(target_folder, relative_path)` pair the caller
	/// should dispatch against.
	///
	/// Cases, in order:
	///
	/// - **Absolute path under a bound folder's root**: routes to
	///   that folder. The returned `relative_path` is the portion
	///   inside the folder's root (or `"."` when the path is the
	///   folder root itself). This is the everyday host-mode form:
	///   the system prompt's "Bound folders" section advertises
	///   each folder by its absolute host path, and the model
	///   joins file-relative paths onto it.
	/// - **Synthetic `/workspace/<name>/...`**: container-mode
	///   form. Routes to the folder whose basename matches
	///   `<name>`. Errors with a clear "no folder bound as `<name>`"
	///   message when the basename doesn't match anything bound.
	///   Kept available in host mode too — a model that's seen
	///   both forms across sessions doesn't fight us about it —
	///   but the system prompt only advertises this form when the
	///   workspace shell container is actually running.
	/// - **Bare relative path starting with another bound folder's
	///   basename** (`<other>/foo.rs`): also routes cross-folder.
	///   Disambiguation: a leading `./` opts out and forces the
	///   path to resolve inside the [`ToolContext`]'s folder, so a
	///   legitimate same-named subdirectory still works.
	/// - **Anything else** (relative paths, absolute paths outside
	///   every bound folder's root): resolved against `cx.folder`
	///   and left for [`WorkspaceHost::resolve`] to validate the
	///   way it always has — an absolute path outside every bound
	///   folder fails with a clear "escapes workspace root" error,
	///   which is the behaviour we want for paths like `/etc/passwd`.
	///
	/// Sub-agents call this with `cx.folder` set to their own
	/// assigned folder. They typically only see one bound folder
	/// in their tool context (the one they were spawned against),
	/// but the routing logic is identical — it just collapses to
	/// the no-op case when the path is already inside `cx.folder`.
	///
	/// Returns a [`ResolvedTarget`]:
	///
	/// - [`ResolvedTarget::InWorkspace`] when the path lands inside
	///   a bound folder — the everyday case. The tool dispatches
	///   through that folder's [`WorkspaceHost`], which keeps the
	///   editorconfig / lint-staged / format-on-save pipeline and
	///   (in the container world) the in-container `RemoteHost`.
	/// - [`ResolvedTarget::OutOfWorkspace`] when the path is
	///   absolute and outside every bound folder root (and not a
	///   `/workspace/<name>` synthetic path). The tool routes
	///   through the container-aware out-of-workspace primitives
	///   ([`oow_read_file`] / [`oow_write_file`] / [`oow_read_dir`]),
	///   which `docker exec` into the workspace shell container when
	///   it's running and fall back to the host filesystem
	///   otherwise — same target `bash` would pick. The host-root
	///   gate is *not* applied here: arbitrary-path access is the
	///   point. See ADR 0025.
	async fn resolve_target(
		&self,
		raw: &str,
		cx: &ToolContext,
		tool: &'static str,
	) -> Result<ResolvedTarget, CoderError> {
		let folders = self.workspaces.folders().await;
		let active_name = cx.folder.folder.name.as_str();
		let path = Utf8Path::new(raw);

		if path.is_absolute() {
			// Absolute path that lands under a bound folder's
			// root: route to that folder. The "longest matching
			// root" pick handles the (rare) case where one bound
			// folder is a strict ancestor of another — the inner
			// one wins, matching how the file tree groups them.
			if let Some((target, relative)) = match_bound_folder_root(&folders, path) {
				let (target, relative) = collapse_worktree_parent(cx, target, relative);
				return Ok(ResolvedTarget::in_workspace(target, relative));
			}
			// Synthetic `/workspace/<name>/...`: the container-mode
			// surface from the system prompt. Looked up by basename
			// against the bound folders just like the absolute-root
			// case above.
			//
			// A `/workspace/<name>/...` path whose `<name>` is a
			// bound folder routes to that folder's host. A
			// `/workspace/<name>` whose `<name>` is *not* bound
			// errors with the list of what is bound — the system
			// prompt only ever advertises this form for bound
			// folders, so an unbound `<name>` is a model mistake
			// worth a precise nudge rather than a silent fall to
			// the out-of-workspace path (where it'd surface as a
			// generic "no such file" against a container mount
			// point that doesn't exist).
			if let Ok(rest) = path.strip_prefix("/workspace") {
				let mut comps = rest.components();
				if let Some(camino::Utf8Component::Normal(first)) = comps.next() {
					let target = folders
						.iter()
						.find(|f| f.folder.name == first)
						.cloned()
						.ok_or_else(|| unbound_folder_error(tool, raw, first, &folders))?;
					let tail = rest.strip_prefix(first).unwrap_or(Utf8Path::new(""));
					let s = tail.as_str();
					let resolved = if s.is_empty() { ".".to_string() } else { s.to_string() };
					let (target, resolved) = collapse_worktree_parent(cx, target, resolved);
					return Ok(ResolvedTarget::in_workspace(target, resolved));
				}
			}
			// Absolute path outside every bound folder root (and
			// not a `/workspace/<name>` synthetic): route to the
			// container-aware out-of-workspace primitives. No
			// host-root gate — the agent can reach any path the
			// bash target can. See ADR 0025.
			return Ok(ResolvedTarget::out_of_workspace(path.to_path_buf()));
		}

		let mut comps = path.components();
		match comps.next() {
			// Leading `./` is the explicit "I mean a path *inside*
			// `cx.folder`, even if the first segment looks like a
			// sibling's basename" opt-out. Pass through untouched.
			Some(camino::Utf8Component::CurDir) => Ok(ResolvedTarget::in_workspace(cx.folder.clone(), raw.to_string())),
			Some(camino::Utf8Component::Normal(name)) => {
				if name != active_name {
					if let Some(other) = folders.iter().find(|f| f.folder.name == name).cloned() {
						// `<other-name>/<rest>` — strip the basename
						// and route to that folder. Bare `<other-name>`
						// (no tail) becomes `.`.
						let tail_str = path
							.strip_prefix(name)
							.map(|t| t.as_str().to_string())
							.unwrap_or_default();
						let resolved = if tail_str.is_empty() { ".".to_string() } else { tail_str };
						let (other, resolved) = collapse_worktree_parent(cx, other, resolved);
						return Ok(ResolvedTarget::in_workspace(other, resolved));
					}
				}
				Ok(ResolvedTarget::in_workspace(cx.folder.clone(), raw.to_string()))
			}
			// Relative paths that aren't a sibling basename (`..`,
			// etc.) resolve against the active folder's host, which
			// keeps the historical `..`-escape rejection for plain
			// relative inputs. Only *absolute* paths reach the
			// out-of-workspace branch above.
			_ => Ok(ResolvedTarget::in_workspace(cx.folder.clone(), raw.to_string())),
		}
	}

	/// Back-compat thin wrapper that flattens [`resolve_target`] to
	/// the `(folder, relative)` pair for the in-workspace case and
	/// errors on out-of-workspace inputs. The four fs tools call
	/// [`resolve_target`] directly so they can branch; this stays
	/// for any caller that only ever wants the in-workspace shape.
	#[cfg(test)]
	async fn resolve_workspace_path(
		&self,
		raw: &str,
		cx: &ToolContext,
		tool: &'static str,
	) -> Result<(Arc<WorkspaceFolderEntry>, String), CoderError> {
		match self.resolve_target(raw, cx, tool).await? {
			ResolvedTarget::InWorkspace { folder, relative } => Ok((folder, relative)),
			ResolvedTarget::OutOfWorkspace { abs_path } => Err(CoderError::tool_failed(
				tool,
				format!("{abs_path} is outside every bound folder"),
			)),
		}
	}

	/// Tool definitions to advertise to the model on every chat call.
	///
	/// `web_search` is gated on a configured Tavily API key: with no
	/// key the model never sees the definition, so it can't be tempted
	/// to call a tool that's guaranteed to error. `web_fetch` is
	/// always advertised — Jina Reader's free tier needs no key.
	pub fn definitions(&self) -> Vec<ToolDefinition> {
		let mut defs = vec![
			ToolDefinition::function(
				"read_file",
				"Read the contents of a file. Returns the file's text, with each line prefixed by `<line_number>|<line>`. Treat the prefix as metadata — it is not part of the file. Optional `start_line` / `end_line` (1-based, inclusive) read just a slice; both omitted means read the whole file (capped at 200 kB). Image files (png, jpg/jpeg, gif, webp) come back as attached images you can see; other binary files error.",
				json!({
					"type": "object",
					"properties": {
					"path": {
						"type": "string",
						"description": "A relative path against the active folder (`src/foo.rs`), or any absolute path."
					},
					"start_line": {
							"type": "integer",
							"description": "First line to include, 1-based and inclusive. Omit to start at line 1."
						},
						"end_line": {
							"type": "integer",
							"description": "Last line to include, 1-based and inclusive. Clamped silently if past EOF. Omit to read to EOF."
						}
					},
					"required": ["path"]
				}),
			),
			ToolDefinition::function(
				"list_dir",
				"List the immediate contents of a directory. Returns one entry per line in `kind  name` form.",
				json!({
					"type": "object",
					"properties": {
					"path": {
						"type": "string",
						"description": "`.` for the active folder root, a relative path against it (`src/`), or any absolute path.",
						"default": "."
					}
					}
				}),
			),
			ToolDefinition::function(
				"grep",
				"Regex search across the workspace folder, gitignore-aware. Returns one match per line in `path:line: match` form. The `line` field is the 1-based line number, so a follow-up `read_file` can target it directly via `start_line` / `end_line`.",
				json!({
					"type": "object",
					"properties": {
						"pattern": {
							"type": "string",
							"description": "Rust-syntax regular expression."
						},
						"case_sensitive": {
							"type": "boolean",
							"description": "Match case-sensitively. Defaults to false (smart-case off)."
						},
						"max_matches": {
							"type": "integer",
							"description": "Stop after this many matches. Defaults to 200."
						}
					},
					"required": ["pattern"]
				}),
			),
			ToolDefinition::function(
				"bash",
				"Run a shell command in the active workspace folder. Returns stdout, stderr, exit_code. Times out after 120s by default. For long-running commands, pass `detach: true` — the process keeps running and you get back an `id` to poll with `read_process`.",
				json!({
					"type": "object",
					"properties": {
						"cmd": {
							"type": "string",
							"description": "Shell command. Host: `bash -lc <cmd>`. Workspace shell container: `docker exec … bash -c <cmd>` so moon-base `ENV PATH` (fnm, cargo, bun, …) is preserved."
						},
						"timeout_ms": {
							"type": "integer",
							"description": "Soft timeout in milliseconds. Capped at 600000 (10 minutes). Ignored when `detach` is true."
						},
						"detach": {
							"type": "boolean",
							"description": "When true, spawn the command as a detached background process. Returns immediately with `{ detached, id, pid, log_path }` instead of waiting for completion. Use `read_process(id)` to poll status and tail output, and `stop_process(id)` to kill it. The process is killed automatically when the turn ends if still running."
						}
					},
					"required": ["cmd"]
				}),
			),
			ToolDefinition::function(
				"read_process",
				"Poll a detached background process spawned by `bash` with `detach: true`. Returns its running status, exit code (if finished), and the tail of its stdout+stderr log. Pass `wait_ms` to block until the process exits (avoids busy-polling) — the call returns as soon as the process finishes or the wait elapses.",
				json!({
					"type": "object",
					"properties": {
						"id": {
							"type": "string",
							"description": "The process id returned by `bash` with `detach: true`."
						},
						"wait_ms": {
							"type": "integer",
							"description": "If > 0, block up to this many milliseconds waiting for the process to exit before returning (capped at 600000 = 10 minutes). One `read_process(id, wait_ms=600000)` per ten minutes beats hundreds of instant polls."
						},
						"tail_bytes": {
							"type": "integer",
							"description": "Maximum bytes of log output to return (from the end of the log). Defaults to 8000, capped at 64000."
						}
					},
					"required": ["id"]
				}),
			),
			ToolDefinition::function(
				"stop_process",
				"Kill a detached background process if it is still running, then reap it. No-op (returns `killed: false`) if the process already exited. Returns the exit code.",
				json!({
					"type": "object",
					"properties": {
						"id": {
							"type": "string",
							"description": "The process id returned by `bash` with `detach: true`."
						}
					},
					"required": ["id"]
				}),
			),
			ToolDefinition::function(
				"write_file",
				"Overwrite a file with new content (or create it if missing). Use for new files or whole-file rewrites; prefer `edit_file` for surgical changes inside a large file. Missing parent directories are created automatically (`mkdir -p`).",
				json!({
					"type": "object",
					"properties": {
					"path": {
						"type": "string",
						"description": "A relative path against the active folder (`src/foo.rs`), or any absolute path. Created if it does not exist."
					},
					"content": {
							"type": "string",
							"description": "Full file contents. Whatever you pass becomes the file verbatim — include the trailing newline if you want one."
						}
					},
					"required": ["path", "content"]
				}),
			),
			ToolDefinition::function(
				"edit_file",
				"Replace a substring in a file. `find` is whitespace-tolerant (indent shifts and interior whitespace runs forgiven) and must be unique unless `occurrence` is given. Empty `replace` deletes. Format-on-save runs once per touched file at the end of the turn, so mid-turn the file holds exactly the bytes you wrote.",
				json!({
					"type": "object",
					"properties": {
					"path": {
						"type": "string",
						"description": "A relative path against the active folder, or any absolute path. The file must already exist."
						},
						"find": {
							"type": "string",
							"description": "Substring to locate. No regex; whitespace-tolerant."
						},
						"replace": {
							"type": "string",
							"description": "Replacement text. Empty string deletes the matched span."
						},
						"occurrence": {
							"type": "integer",
							"description": "1-based index of which match to replace when `find` matches multiple times. Omit to require exactly one match."
						}
					},
					"required": ["path", "find", "replace"]
				}),
			),
			ToolDefinition::function(
				"web_fetch",
				"Fetch a single web page and return its main content as clean Markdown. Backed by Jina Reader — strips boilerplate, preserves headings / links / code blocks. Use this to read documentation, blog posts, RFCs, release notes, or any URL surfaced by `web_search`. Only `http`/`https` URLs are accepted. Long pages are truncated at ~200 kB; if `truncated` is true, fetch a more specific sub-page rather than re-fetching the same URL.",
				json!({
					"type": "object",
					"properties": {
						"url": {
							"type": "string",
							"description": "Absolute http or https URL to fetch."
						}
					},
					"required": ["url"]
				}),
			),
			ToolDefinition::function(
				"todo_write",
				"Maintain a small in-context todo list for the current task. Returns the canonical full list after the call. `merge: false` (default) replaces the list wholesale — pass `todos: []` to clear. `merge: true` matches incoming items by `id` and updates in place; unknown ids are appended; items you don't mention are left untouched. Each item must carry `id` and `content`; `status` defaults to `pending` if omitted.",
				json!({
					"type": "object",
					"properties": {
						"todos": {
							"type": "array",
							"description": "`id` and `content` are required on every entry; `status` defaults to `pending`.",
							"items": {
								"type": "object",
								"properties": {
									"id": {
										"type": "string",
										"description": "Stable identifier you assign and reuse across calls. Match an existing item with `merge: true` to update it."
									},
									"content": {
										"type": "string",
										"description": "Short imperative description (\"Add foo\", \"Wire up bar\")."
									},
									"status": {
										"type": "string",
										"enum": ["pending", "in_progress", "completed", "cancelled"],
										"description": "Lifecycle state. Mark exactly one item `in_progress` while working on it; flip to `completed` or `cancelled` when done. Defaults to `pending` if omitted.",
										"default": "pending"
									}
								},
								"required": ["id", "content"]
							}
						},
						"merge": {
							"type": "boolean",
							"description": "When true, items are matched by `id` and merged into the current list (unknown ids appended; unmentioned ids left alone). When false, the incoming list replaces the current one wholesale. Default false.",
							"default": false
						}
					},
					"required": ["todos"]
				}),
			),
		];
		if self.web.has_tavily_key() {
			defs.push(ToolDefinition::function(
				"web_search",
				"Search the open web. Returns a small list of `{ title, url, snippet }` entries (plus `published_date` when known) sorted by Tavily's relevance ranking. Use this when you need information that might be missing or outdated in your training data — recent releases, API docs you don't already know, error messages quoted online, news, package changelogs. After picking a promising URL, call `web_fetch` on it for the full page. Don't use `web_search` for facts you're confident about, and don't use it for anything inside the workspace — that's what `grep` / `read_file` / `bash` are for.",
				json!({
					"type": "object",
					"properties": {
						"query": {
							"type": "string",
							"description": "Free-form search query, same way you'd type into a search engine. Be specific — include version numbers, language names, error message fragments."
						},
						"max_results": {
							"type": "integer",
							"description": "Maximum number of results to return. Defaults to 8; capped at 20."
						}
					},
					"required": ["query"]
				}),
			));
		}
		defs
	}

	/// Meta-tool definitions for the workspace's enabled MCP
	/// servers (ADR 0033). Empty when none are enabled, so the
	/// model never sees tools that are guaranteed to error — same
	/// posture as the Tavily-gated `web_search`.
	///
	/// Deliberately a meta-tool surface (`mcp_list_tools` +
	/// `mcp_call`) rather than advertising every server tool
	/// directly: the tool list stays stable across servers, and
	/// per-server schemas only cost context when the model actually
	/// asks for them. The enabled-server list rides in the
	/// descriptions plus an `enum` on `server`, so a hallucinated
	/// server name fails schema-side.
	pub async fn mcp_definitions(&self) -> Vec<ToolDefinition> {
		let config = self.mcp_workspace_config().await;
		let servers = crate::mcp::enabled_servers(&config);
		if servers.is_empty() {
			return Vec::new();
		}
		let ids: Vec<&str> = servers.iter().map(|s| s.id.as_str()).collect();
		let catalog = servers
			.iter()
			.map(|s| format!("- `{}` — {}", s.id, s.description))
			.collect::<Vec<_>>()
			.join("\n");
		vec![
			ToolDefinition::function(
				"mcp_list_tools",
				format!(
					"List the tools an enabled MCP (Model Context Protocol) server exposes: name, description, and JSON-Schema input schema per tool. Call this once before the first `mcp_call` against a server in this session — don't guess tool names or argument shapes.\n\nEnabled servers:\n{catalog}"
				),
				json!({
					"type": "object",
					"properties": {
						"server": {
							"type": "string",
							"enum": ids,
							"description": "Id of the enabled MCP server to inspect."
						}
					},
					"required": ["server"]
				}),
			),
			ToolDefinition::function(
				"mcp_call",
				format!(
					"Invoke one tool on an enabled MCP server. `args` must match the tool's input schema as reported by `mcp_list_tools`. The server process is spawned on first use and stays alive for the session, so state (e.g. an open browser page) persists across calls.\n\nEnabled servers:\n{catalog}"
				),
				json!({
					"type": "object",
					"properties": {
						"server": {
							"type": "string",
							"enum": ids,
							"description": "Id of the enabled MCP server."
						},
						"tool": {
							"type": "string",
							"description": "Tool name exactly as reported by `mcp_list_tools`."
						},
						"args": {
							"type": "object",
							"description": "Arguments matching the tool's input schema. Omit for tools that take none."
						}
					},
					"required": ["server", "tool"]
				}),
			),
		]
	}

	/// Terminal-inspection tools, advertised only when the session's
	/// own project actually has a terminal open (ADR 0048).
	///
	/// Gated for the same reason the MCP meta-tools are: with no
	/// terminals the pair can only answer "there are none", and an
	/// always-present `list_terminals` invites the model to burn a
	/// round-trip discovering that. The flip side is that the tools
	/// appear mid-session the moment the user opens a terminal —
	/// the tool list is rebuilt every turn, so that lands without
	/// any special handling.
	///
	/// Scope is the session's folder: a worktree session sees its
	/// worktree's terminals, its parent project's are somebody
	/// else's business.
	pub async fn terminal_definitions(&self, cx: &ToolContext) -> Vec<ToolDefinition> {
		let folder = Utf8Path::new(cx.folder.folder.path.as_str());
		let open = self.terminals.list_for_folder(folder).await;
		if open.is_empty() {
			return Vec::new();
		}
		let inventory = open
			.iter()
			.map(|t| {
				let state = match (t.running, t.exit_code) {
					(true, _) => "running".to_owned(),
					(false, Some(code)) => format!("exited {code}"),
					(false, None) => "exited".to_owned(),
				};
				format!("- `{}` — {} shell in `{}` ({state})", t.id, t.kind.as_str(), t.cwd)
			})
			.collect::<Vec<_>>()
			.join("\n");
		vec![
			ToolDefinition::function(
				"list_terminals",
				format!(
					"List the terminals the user has open for this project, with each one's shell target, working directory, and whether its shell is still running. Use this to find the id to pass to `read_terminal`.\n\nCurrently open:\n{inventory}"
				),
				json!({
					"type": "object",
					"properties": {}
				}),
			),
			ToolDefinition::function(
				"read_terminal",
				format!(
					"Read the recent output of one of the user's open terminals — what is actually on their screen, with escape sequences applied and `\\r`-redrawn progress lines collapsed. Use it to see how a dev server, watcher, or test run the user started is doing instead of starting a competing one with `bash`. When the user pastes terminal output into their message, the `<terminal_output>` block's `terminal_id` attribute is a valid id here — use it to read the live terminal instead of treating the paste as everything there is. Read-only: this cannot type into a terminal or run anything. Terminals may contain output the user did not mean to share (tokens, secrets, unrelated projects) — read what you need for the task at hand and don't quote more of it back than necessary.\n\nCurrently open:\n{inventory}"
				),
				json!({
					"type": "object",
					"properties": {
						"id": {
							"type": "string",
							"description": "Terminal id from `list_terminals`."
						},
						"lines": {
							"type": "integer",
							"description": format!("How many lines from the end of the terminal to return. Defaults to {DEFAULT_READ_LINES}, capped at {MAX_READ_LINES}.")
						}
					},
					"required": ["id"]
				}),
			),
		]
	}

	/// Every terminal open for the session's folder.
	async fn list_terminals(&self, cx: &ToolContext) -> Result<Value, CoderError> {
		let folder = Utf8Path::new(cx.folder.folder.path.as_str());
		let open = self.terminals.list_for_folder(folder).await;
		let terminals: Vec<Value> = open.iter().map(terminal_info_json).collect();
		Ok(json!({
			"folder": cx.folder.folder.name,
			"count": terminals.len(),
			"terminals": terminals,
		}))
	}

	/// Rendered tail of one terminal's output.
	///
	/// A terminal belonging to another folder is refused as if it
	/// didn't exist — same shape as an unknown id, listing this
	/// project's terminals so the model can self-correct. Leaking
	/// "that id exists but is another project's" would be a worse
	/// answer than the recovery hint.
	async fn read_terminal(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct ReadTerminalArgs {
			id: String,
			#[serde(default)]
			lines: Option<usize>,
		}
		let parsed: ReadTerminalArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("read_terminal", err.to_string()))?;
		let folder = Utf8Path::new(cx.folder.folder.path.as_str());
		let scoped = self.terminals.list_for_folder(folder).await;
		if !scoped.iter().any(|t| t.id == parsed.id) {
			let known: Vec<&str> = scoped.iter().map(|t| t.id.as_str()).collect();
			return Err(CoderError::invalid_args(
				"read_terminal",
				format!(
					"no terminal `{}` open for this project; open terminals: [{}]",
					parsed.id,
					known.join(", ")
				),
			));
		}
		let read = self
			.terminals
			.read(&parsed.id, parsed.lines)
			.await
			.ok_or_else(|| CoderError::tool_failed("read_terminal", "terminal closed while being read"))?;
		let mut out = terminal_info_json(&read.info);
		let text = read.lines.join("\n");
		if let Value::Object(map) = &mut out {
			map.insert("lines_returned".into(), json!(read.lines.len()));
			map.insert("truncated".into(), json!(read.truncated));
			map.insert("output".into(), json!(text));
		}
		Ok(out)
	}

	/// The workspace's persisted MCP config. Load failures degrade
	/// to the empty default with a warn — same posture as every
	/// other `session.json` consumer.
	async fn mcp_workspace_config(&self) -> moon_protocol::coder_mcp::CoderMcpWorkspaceConfig {
		let workspace_id = self.workspaces.workspace_id().await;
		match moon_core::session::load(&self.workspaces_dir, &workspace_id).await {
			Ok(session) => session.coder_mcp,
			Err(err) => {
				tracing::warn!(error = %err, "mcp: could not read session.json, treating as no servers");
				Default::default()
			}
		}
	}

	/// Resolve where a server process should spawn: the same probe
	/// as `bash` — container when the workspace shell container is
	/// `Running`, host otherwise. MCP servers ride the workspace
	/// mode rather than carrying a per-server run target.
	///
	/// Also assembles the `roots` set advertised at handshake: one
	/// entry per bound folder in the spawn target's path space,
	/// **active folder first** (servers that take a single
	/// workspace root use that one), plus the mount pairs needed
	/// to translate container paths back for the model.
	async fn mcp_spawn_target(&self, cx: &ToolContext) -> McpSpawnTarget {
		let host_cwd = cx.folder.folder.path.clone();
		let target = resolve_bash_target(&self.workspaces, &self.workspaces_dir, cx.force_host_bash(), &cx.folder).await;
		// Bound folders with the active one hoisted to the front.
		let mut folders: Vec<String> = vec![host_cwd.clone()];
		for entry in self.workspaces.folders().await {
			if entry.folder.path != host_cwd {
				folders.push(entry.folder.path.clone());
			}
		}
		let root_name = |path: &str| Utf8Path::new(path).file_name().unwrap_or("workspace").to_owned();
		if target != BASH_TARGET_CONTAINER {
			let roots = folders
				.iter()
				.map(|path| McpRoot {
					path: path.clone(),
					name: root_name(path),
				})
				.collect();
			return McpSpawnTarget {
				kind: McpSpawnKind::Host { cwd: host_cwd },
				roots,
				mounts: Vec::new(),
			};
		}
		let workspace_id = self.workspaces.workspace_id().await;
		let container_path = |path: &str| {
			TerminalTarget::container_cwd_for_folder(Utf8Path::new(path)).unwrap_or_else(|| Utf8PathBuf::from("/workspace"))
		};
		let mut roots = Vec::with_capacity(folders.len());
		let mut mounts = Vec::with_capacity(folders.len());
		for path in &folders {
			let container_root = container_path(path).to_string();
			roots.push(McpRoot {
				path: container_root.clone(),
				name: root_name(path),
			});
			mounts.push(McpMount {
				host_root: path.clone(),
				container_root,
			});
		}
		McpSpawnTarget {
			kind: McpSpawnKind::Container {
				name: container_name_for_workspace(&workspace_id),
				cwd: container_path(&host_cwd).to_string(),
			},
			roots,
			mounts,
		}
	}

	/// Look up an enabled server by id, erroring with the enabled
	/// list so the model can self-correct — same recovery shape as
	/// the unbound-folder error in `resolve_target`.
	async fn mcp_enabled_server(
		&self,
		tool: &'static str,
		id: &str,
	) -> Result<moon_protocol::coder_mcp::McpServerConfig, CoderError> {
		let config = self.mcp_workspace_config().await;
		let servers = crate::mcp::enabled_servers(&config);
		servers.iter().find(|s| s.id == id).cloned().ok_or_else(|| {
			let enabled: Vec<&str> = servers.iter().map(|s| s.id.as_str()).collect();
			CoderError::invalid_args(
				tool,
				format!(
					"no enabled MCP server `{id}`; enabled servers: [{}]",
					enabled.join(", ")
				),
			)
		})
	}

	async fn mcp_list_tools(
		&self,
		args: &Value,
		cx: &ToolContext,
		cancel: &CancellationToken,
	) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct McpListToolsArgs {
			server: String,
		}
		let parsed: McpListToolsArgs = serde_json::from_value(args.clone())
			.map_err(|err| CoderError::invalid_args("mcp_list_tools", err.to_string()))?;
		let server = self.mcp_enabled_server("mcp_list_tools", &parsed.server).await?;
		let spawn = self.mcp_spawn_target(cx).await;
		let tools = self.mcp.list_tools(&server, &spawn, cancel).await?;
		Ok(json!({
			"server": parsed.server,
			"count": tools.len(),
			"tools": tools,
		}))
	}

	async fn mcp_call(&self, args: &Value, cx: &ToolContext, cancel: &CancellationToken) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct McpCallArgs {
			server: String,
			tool: String,
			#[serde(default)]
			args: Value,
		}
		let parsed: McpCallArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("mcp_call", err.to_string()))?;
		let server = self.mcp_enabled_server("mcp_call", &parsed.server).await?;
		let spawn = self.mcp_spawn_target(cx).await;
		let call_args = if parsed.args.is_null() { json!({}) } else { parsed.args };
		let images_ok = self.images_supported().await;
		self
			.mcp
			.call_tool(&server, &spawn, &parsed.tool, call_args, cancel, images_ok)
			.await
	}

	pub async fn dispatch(
		&self,
		name: &str,
		args: &Value,
		cx: &ToolContext,
		cancel: &CancellationToken,
	) -> Result<Value, CoderError> {
		match name {
			"read_file" => self.read_file(args, cx).await,
			"list_dir" => self.list_dir(args, cx).await,
			"grep" => self.grep(args, cx).await,
			// `bash` deliberately is *not* mode-gated: a Research
			// sub-agent gets to run inspection commands (`git log`,
			// `cargo check`, `pytest --collect-only`, …). The
			// "don't mutate" half is enforced via the sub-agent's
			// system prompt — see Phase C's `run_subagent`.
			"bash" => self.bash(args, cx, cancel).await,
			"read_process" => self.read_process(args, cx, cancel).await,
			"stop_process" => self.stop_process(args, cx).await,
			// Reading a terminal is read-only against the user's own
			// screen, so it isn't mode-gated — a Research sub-agent
			// checking why the dev server is unhappy is the point.
			"list_terminals" => self.list_terminals(cx).await,
			"read_terminal" => self.read_terminal(args, cx).await,
			"write_file" => {
				if !cx.mode.allows_writes() {
					return Err(CoderError::read_only_mode("write_file"));
				}
				self.write_file(args, cx).await
			}
			"edit_file" => {
				if !cx.mode.allows_writes() {
					return Err(CoderError::read_only_mode("edit_file"));
				}
				self.edit_file(args, cx).await
			}
			// Web tools are intentionally *not* mode-gated: a
			// Research sub-agent reading the open web is exactly
			// the kind of read-only inspection the mode exists for.
			"web_search" => self.web_search(args, cancel).await,
			"web_fetch" => self.web_fetch(args, cancel).await,
			// MCP meta-tools are, like `bash`, not mode-gated: a
			// Research sub-agent driving a read-heavy MCP server
			// is legitimate, and "don't mutate" stays prompt-
			// enforced. The definitions only exist when the
			// workspace has enabled servers.
			"mcp_list_tools" => self.mcp_list_tools(args, cx, cancel).await,
			"mcp_call" => self.mcp_call(args, cx, cancel).await,
			other => Err(CoderError::UnknownTool(other.to_string())),
		}
	}

	async fn read_file(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct ReadFileArgs {
			#[serde(alias = "file_path", alias = "file")]
			path: String,
			#[serde(default)]
			start_line: Option<u32>,
			#[serde(default)]
			end_line: Option<u32>,
		}
		let parsed: ReadFileArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("read_file", err.to_string()))?;
		if let (Some(start), Some(end)) = (parsed.start_line, parsed.end_line) {
			if start == 0 || end < start {
				return Err(CoderError::invalid_args(
					"read_file",
					"start_line / end_line must be 1-based and end_line >= start_line",
				));
			}
		}
		if matches!(parsed.start_line, Some(0)) || matches!(parsed.end_line, Some(0)) {
			return Err(CoderError::invalid_args("read_file", "line numbers are 1-based"));
		}
		let resolved = self.resolve_target(&parsed.path, cx, "read_file").await?;
		let result = match &resolved {
			ResolvedTarget::InWorkspace { folder, relative } => folder.host.read_file(Utf8Path::new(relative)).await?,
			ResolvedTarget::OutOfWorkspace { abs_path } => self.oow_read_file(abs_path, cx).await?,
		};
		if result.is_binary {
			// Image files aren't an error when the model can see:
			// return the pixels as a typed image attachment (the
			// runner's `images` convention) — reading back a
			// playwright screenshot is the case this exists for.
			// A text-only model gets a hard error naming the cause
			// instead, so it can adapt (describe the file path to
			// the user, fall back to text alternatives) rather than
			// believing it received a screenshot. Other binaries
			// keep the old hard error.
			if let Some(mime) = image_mime_for(&parsed.path) {
				if !self.images_supported().await {
					return Err(CoderError::tool_failed(
						"read_file",
						"image file, but the active model does not accept image input — work from text sources instead",
					));
				}
				return self.read_image_result(&resolved, &parsed.path, mime, cx).await;
			}
			return Err(CoderError::tool_failed("read_file", "binary file"));
		}
		let total_lines = if result.text.is_empty() {
			0
		} else {
			result.text.lines().count() as u32
		};
		let start_line = parsed.start_line.unwrap_or(1);
		let end_line = parsed.end_line.unwrap_or(u32::MAX);
		let (rendered, byte_truncated) = format_numbered_lines(&result.text, start_line, end_line);
		Ok(json!({
			"path": parsed.path,
			"content": rendered,
			"start_line": start_line,
			// `end_line` in the response is the *effective* end after
			// clamping to EOF, so the model can tell when its
			// requested range was shorter than asked for.
			"end_line": end_line.min(total_lines.max(1)),
			"total_lines": total_lines,
			"truncated": byte_truncated,
			"mtime_ms": result.mtime_ms,
		}))
	}

	async fn list_dir(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct ListDirArgs {
			#[serde(default = "default_dot")]
			path: String,
		}
		fn default_dot() -> String {
			".".into()
		}
		let parsed: ListDirArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("list_dir", err.to_string()))?;
		let entries = match self.resolve_target(&parsed.path, cx, "list_dir").await? {
			ResolvedTarget::InWorkspace { folder, relative } => folder.host.read_dir(Utf8Path::new(&relative)).await?,
			ResolvedTarget::OutOfWorkspace { abs_path } => self.oow_read_dir(&abs_path, cx).await?,
		};
		let mut out = String::new();
		for e in &entries {
			let kind = match e.kind {
				moon_protocol::fs::EntryKind::Dir => "dir ",
				moon_protocol::fs::EntryKind::File => "file",
				moon_protocol::fs::EntryKind::Symlink => "link",
				moon_protocol::fs::EntryKind::Other => "?   ",
			};
			out.push_str(kind);
			out.push(' ');
			out.push_str(&e.name);
			out.push('\n');
		}
		Ok(json!({
			"path": parsed.path,
			"entries": out,
			"count": entries.len(),
		}))
	}

	async fn grep(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct GrepArgs {
			pattern: String,
			#[serde(default)]
			case_sensitive: bool,
			#[serde(default)]
			max_matches: Option<u32>,
		}
		let parsed: GrepArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("grep", err.to_string()))?;
		let folder = &cx.folder;
		// We don't have a `WorkspaceHost::content_search` method yet —
		// the existing `moon_core::search::search_content` is a free
		// function that takes a `Utf8Path` root. For local hosts that
		// matches the active folder; container-aware routing arrives
		// when `WorkspaceHost` grows a `content_search` trait method
		// (Phase 6.x or sooner if `RemoteHost` lands first).
		let root = camino::Utf8PathBuf::from(folder.folder.path.clone());
		let options = ContentSearchOptions {
			query: parsed.pattern.clone(),
			case_sensitive: parsed.case_sensitive,
			regex: true,
			max_matches: parsed.max_matches.unwrap_or(200) as usize,
			..Default::default()
		};
		let result = tokio::task::spawn_blocking(move || moon_core::search::search_content(&root, &options))
			.await
			.map_err(|err| CoderError::Internal(format!("grep join error: {err}")))??;
		let formatted = format_grep_hits(&result.hits);
		Ok(json!({
			"pattern": parsed.pattern,
			"matches": formatted,
			"count": result.hits.len(),
			"truncated": result.truncated,
		}))
	}

	async fn write_file(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct WriteFileArgs {
			path: String,
			content: String,
		}
		let parsed: WriteFileArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("write_file", err.to_string()))?;
		let result = match self.resolve_target(&parsed.path, cx, "write_file").await? {
			ResolvedTarget::InWorkspace { folder, relative } => {
				let resolved = Utf8Path::new(&relative);
				// Raw bytes-to-disk: the format-on-save pipeline
				// (editorconfig + lint-staged) runs at turn end via
				// `FormatQueue::drain` against the union of every
				// path touched this turn. Doing the format inline
				// here would mean (a) re-spawning prettier / eslint
				// / rustfmt once per edit even when the model is
				// about to edit the same file again two tool calls
				// later, and (b) `eslint --fix` stripping an
				// "unused" import in between the model adding the
				// import and adding its first use. See the ADR
				// superseding 0013's per-call invocation.
				let result = folder.host.write_file(resolved, &parsed.content).await?;
				cx.format_queue.record(&folder, resolved);
				result
			}
			// Out-of-workspace writes bypass format-on-save
			// entirely: there's no bound-folder host to anchor the
			// editorconfig / lint-staged cascade, and an external
			// file (`/etc/hosts`, `~/.config/...`) has no project
			// config to apply. Raw bytes, no FormatQueue entry.
			ResolvedTarget::OutOfWorkspace { abs_path } => self.oow_write_file(&abs_path, &parsed.content, cx).await?,
		};
		Ok(json!({
			"path": parsed.path,
			"bytes_written": parsed.content.len(),
			"mtime_ms": result.mtime_ms,
		}))
	}

	async fn edit_file(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct EditFileArgs {
			#[serde(alias = "file_path", alias = "file")]
			path: String,
			find: String,
			replace: String,
			#[serde(default)]
			occurrence: Option<usize>,
		}
		let parsed: EditFileArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("edit_file", err.to_string()))?;
		if parsed.find.is_empty() {
			return Err(CoderError::invalid_args("edit_file", "`find` must not be empty"));
		}
		let target = self.resolve_target(&parsed.path, cx, "edit_file").await?;
		let original = match &target {
			ResolvedTarget::InWorkspace { folder, relative } => folder.host.read_file(Utf8Path::new(relative)).await?,
			ResolvedTarget::OutOfWorkspace { abs_path } => self.oow_read_file(abs_path, cx).await?,
		};
		if original.is_binary {
			return Err(CoderError::tool_failed("edit_file", "binary file"));
		}

		let plan = locate_edit(
			&original.text,
			&parsed.find,
			&parsed.replace,
			parsed.occurrence,
			&parsed.path,
		)?;

		let mut new_text = String::with_capacity(original.text.len() - (plan.end - plan.start) + plan.replace_text.len());
		new_text.push_str(&original.text[..plan.start]);
		new_text.push_str(&plan.replace_text);
		new_text.push_str(&original.text[plan.end..]);

		let result = match target {
			ResolvedTarget::InWorkspace { folder, relative } => {
				let path = Utf8Path::new(&relative);
				// Raw bytes-to-disk; format-on-save runs at turn
				// end. See the `write_file` tool above for the
				// rationale.
				let result = folder.host.write_file(path, &new_text).await?;
				cx.format_queue.record(&folder, path);
				result
			}
			// Out-of-workspace: raw bytes, no format-on-save. Same
			// reasoning as `write_file`'s out-of-workspace arm.
			ResolvedTarget::OutOfWorkspace { abs_path } => self.oow_write_file(&abs_path, &new_text, cx).await?,
		};
		Ok(json!({
			"path": parsed.path,
			"replaced_at_byte": plan.start,
			"bytes_written": new_text.len(),
			"mtime_ms": result.mtime_ms,
			"occurrence": plan.occurrence,
			"total_matches": plan.total_matches,
			"match_mode": plan.mode,
		}))
	}

	async fn web_search(&self, args: &Value, cancel: &CancellationToken) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct WebSearchArgs {
			query: String,
			#[serde(default)]
			max_results: Option<u32>,
		}
		let parsed: WebSearchArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("web_search", err.to_string()))?;
		let max_results = parsed.max_results.unwrap_or_else(WebClient::default_search_max_results);
		let results = self.web.search(&parsed.query, max_results, cancel).await?;
		let count = results.len();
		Ok(json!({
			"query": parsed.query,
			"results": results,
			"count": count,
		}))
	}

	async fn web_fetch(&self, args: &Value, cancel: &CancellationToken) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct WebFetchArgs {
			url: String,
		}
		let parsed: WebFetchArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("web_fetch", err.to_string()))?;
		let fetched = self.web.fetch(&parsed.url, cancel).await?;
		Ok(json!({
			"url": fetched.url,
			"markdown": fetched.markdown,
			"truncated": fetched.truncated,
			"bytes": fetched.markdown.len(),
		}))
	}

	async fn bash(&self, args: &Value, cx: &ToolContext, cancel: &CancellationToken) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct BashArgs {
			cmd: String,
			#[serde(default)]
			timeout_ms: Option<u64>,
			#[serde(default)]
			detach: bool,
		}
		let parsed: BashArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("bash", err.to_string()))?;
		let folder = &cx.folder;
		let (command, target_kind) = self
			.build_bash_command(folder, &parsed.cmd, cx.force_host_bash())
			.await?;

		// Detached path (ADR 0034): spawn into the per-turn registry,
		// redirect output to a log file, return immediately. The
		// model polls via `read_process` and kills via `stop_process`.
		if parsed.detach {
			let id = cx.background.next_id();
			let (id, log_path, pid) = cx.background.spawn(id, command, parsed.cmd.clone(), target_kind)?;
			return Ok(json!({
				"detached": true,
				"id": id,
				"pid": pid,
				"log_path": log_path.to_string_lossy(),
				"cmd": parsed.cmd,
				"target": target_kind,
			}));
		}

		let timeout = parsed
			.timeout_ms
			.map(Duration::from_millis)
			.unwrap_or(BASH_DEFAULT_TIMEOUT)
			.min(BASH_MAX_TIMEOUT);

		let mut command = command;
		command
			.kill_on_drop(true)
			.stdin(std::process::Stdio::null())
			.stdout(std::process::Stdio::piped())
			.stderr(std::process::Stdio::piped());

		let child = command
			.spawn()
			.map_err(|err| CoderError::tool_failed("bash", format!("spawn failed: {err}")))?;

		let output = tokio::select! {
			biased;
			_ = cancel.cancelled() => return Err(CoderError::Aborted),
			result = tokio::time::timeout(timeout, child.wait_with_output()) => result,
		};

		let output = match output {
			Ok(Ok(o)) => o,
			Ok(Err(err)) => return Err(CoderError::tool_failed("bash", err.to_string())),
			Err(_) => {
				return Err(CoderError::tool_failed(
					"bash",
					format!("timed out after {} ms", timeout.as_millis()),
				));
			}
		};

		let stdout = truncate_bytes(&output.stdout, BASH_OUTPUT_MAX_BYTES);
		let stderr = truncate_bytes(&output.stderr, BASH_OUTPUT_MAX_BYTES);
		Ok(json!({
			"cmd": parsed.cmd,
			"target": target_kind,
			"exit_code": output.status.code(),
			"stdout": stdout,
			"stderr": stderr,
		}))
	}

	/// Poll a detached background process (ADR 0034). Tails the log
	/// file and optionally blocks until the process exits.
	async fn read_process(
		&self,
		args: &Value,
		cx: &ToolContext,
		cancel: &CancellationToken,
	) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct ReadProcessArgs {
			id: String,
			#[serde(default)]
			wait_ms: Option<u64>,
			#[serde(default)]
			tail_bytes: Option<usize>,
		}
		let parsed: ReadProcessArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("read_process", err.to_string()))?;
		let wait_ms = parsed.wait_ms.unwrap_or(BG_DEFAULT_WAIT_MS).min(BG_MAX_WAIT_MS);
		let tail_bytes = parsed
			.tail_bytes
			.unwrap_or(BG_DEFAULT_TAIL_BYTES)
			.min(BG_MAX_TAIL_BYTES);
		cx.background.read(&parsed.id, wait_ms, tail_bytes, cancel).await
	}

	/// Kill a detached background process if still running (ADR 0034).
	async fn stop_process(&self, args: &Value, cx: &ToolContext) -> Result<Value, CoderError> {
		#[derive(Deserialize)]
		struct StopProcessArgs {
			id: String,
		}
		let parsed: StopProcessArgs =
			serde_json::from_value(args.clone()).map_err(|err| CoderError::invalid_args("stop_process", err.to_string()))?;
		cx.background.stop(&parsed.id).await
	}

	/// Build the platform-correct `bash` command for the active
	/// folder. Routing decision flows through
	/// [`resolve_bash_target`] so it stays in lockstep with what the
	/// panel header / `CoderStatus.bash_target` advertises:
	///
	/// - **Container** (workspace shell container is `Running`):
	///   `docker exec -w <container_cwd> <name> bash -c <cmd>`.
	///   Reuses `moon_terminal::container_name_for_workspace` +
	///   `TerminalTarget::container_cwd_for_folder` so the framing
	///   matches terminals and LSP exactly.
	///
	///   **`bash -c` here (not `-lc`).** moon-base sets toolchain
	///   PATH segments via Dockerfile `ENV`. A login shell reads
	///   Debian `/etc/profile`, which resets `PATH` before project
	///   hooks run; non-interactive shells skip `~/.bashrc`, so fnm's
	///   eval never restores Node — yet `docker exec … bash` (PTY
	///   terminals) is interactive and does load `~/.bashrc`. Using a
	///   non-login shell inherits the container env verbatim and
	///   matches what `node`, `cargo`, etc. expect from the image.
	/// - **Host** (otherwise): `bash -lc <cmd>` rooted at the folder.
	///
	/// **Why host uses `bash -lc` and not `sh -lc`.** On most modern
	/// Linuxes `/bin/sh` is `dash`, which as a login shell reads only
	/// `~/.profile`. Most host toolchains (rustup, mise, pyenv, …)
	/// extend PATH from `~/.bashrc` — sometimes additionally in
	/// `~/.profile`, often not. Result: `sh -lc 'cargo …'` returns
	/// "cargo: not found" even though the user's interactive terminal
	/// has cargo on PATH. `bash -lc` reads `~/.bash_profile` (which on
	/// almost every dev box sources `~/.bashrc`). Trade-off: requires
	/// `bash` in the container — true for moon-base, since terminals
	/// (`moon-terminal::target`) already assume it.
	async fn build_bash_command(
		&self,
		folder: &WorkspaceFolderEntry,
		cmd: &str,
		force_host: bool,
	) -> Result<(tokio::process::Command, &'static str), CoderError> {
		let target = resolve_bash_target(&self.workspaces, &self.workspaces_dir, force_host, folder).await;
		if target == BASH_TARGET_CONTAINER {
			let workspace_id = self.workspaces.workspace_id().await;
			let container_name = container_name_for_workspace(&workspace_id);
			// Worktree-backed sessions (ADR 0029) live inside the parent
			// repo at `<parent>/.worktrees/…`, so their `bash` cwd is the
			// parent's `/workspace/<name>` mount plus the relative tail.
			// Everything else falls back to `/workspace` if the host
			// path has no basename — same fallback `moon-terminal` uses
			// for pathological inputs (`/`).
			let folder_path = Utf8Path::new(&folder.folder.path);
			let container_cwd =
				if let moon_protocol::workspace::FolderOrigin::Worktree { parent_path, .. } = &folder.folder.origin {
					moon_core::worktree::worktree_container_path(Utf8Path::new(parent_path), folder_path)
						.unwrap_or_else(|| Utf8PathBuf::from("/workspace"))
				} else {
					TerminalTarget::container_cwd_for_folder(folder_path).unwrap_or_else(|| Utf8PathBuf::from("/workspace"))
				};
			// `docker exec` (no `-it`): we want captured
			// stdout/stderr, not a TTY. Terminals get `-it`; the
			// bash tool doesn't.
			let mut command = tokio::process::Command::new("docker");
			command
				.arg("exec")
				.arg("-w")
				.arg(container_cwd.as_str())
				.arg(&container_name)
				.arg("bash")
				.arg("-c")
				.arg(cmd);
			return Ok((command, BASH_TARGET_CONTAINER));
		}
		let mut command = tokio::process::Command::new("bash");
		command.arg("-lc").arg(cmd).current_dir(folder.folder.path.as_str());
		Ok((command, BASH_TARGET_HOST))
	}

	/// `true` when out-of-workspace fs ops should route through the
	/// workspace shell container. Same probe as `bash` so the file
	/// tools and `bash` agree on where an external path lives — an
	/// agent that `ls`'d `/etc` via `bash` and then `read_file`'d
	/// `/etc/hosts` reaches the same filesystem both times.
	async fn oow_target_is_container(&self, cx: &ToolContext) -> bool {
		resolve_bash_target(&self.workspaces, &self.workspaces_dir, cx.force_host_bash(), &cx.folder).await
			== BASH_TARGET_CONTAINER
	}

	/// `docker exec` (no TTY) against the workspace shell container,
	/// invoking `program` with `args` directly — no shell, so the
	/// path arguments need no quoting and can't be re-interpreted.
	async fn container_exec(&self, program: &str, args: &[&str]) -> tokio::process::Command {
		let workspace_id = self.workspaces.workspace_id().await;
		let container_name = container_name_for_workspace(&workspace_id);
		let mut command = tokio::process::Command::new("docker");
		command.arg("exec").arg(&container_name).arg(program);
		for arg in args {
			command.arg(arg);
		}
		command
	}

	/// Read an arbitrary absolute path, routed to the container when
	/// it's running and the host otherwise. Mirrors the binary /
	/// mtime shape of [`moon_core::read_host_file`] so the
	/// `read_file` tool handles both arms identically.
	///
	/// In-container reads `cat` the file and stat its mtime in one
	/// `docker exec` round-trip via a tiny `sh` wrapper; the host
	/// arm calls [`moon_core::read_host_file`] directly.
	async fn oow_read_file(&self, abs_path: &Utf8Path, cx: &ToolContext) -> Result<ReadFileResult, CoderError> {
		if !self.oow_target_is_container(cx).await {
			return Ok(moon_core::read_host_file(abs_path).await?);
		}
		// `cat -- <path>`: direct exec, no shell, so the path can't
		// be word-split or glob-expanded. mtime comes from a
		// separate `stat` exec — cheaper to reason about than
		// multiplexing both onto one stdout, and the file tools
		// only use mtime as an advisory freshness hint.
		let mut cat = self.container_exec("cat", &["--", abs_path.as_str()]).await;
		let output = run_capturing(&mut cat, "read_file").await?;
		if !output.status.success() {
			return Err(CoderError::tool_failed(
				"read_file",
				container_io_error(abs_path, &output.stderr),
			));
		}
		let mtime_ms = self.container_stat_mtime_ms(abs_path).await;
		if looks_binary_bytes(&output.stdout) {
			return Ok(ReadFileResult {
				text: String::new(),
				mtime_ms,
				is_binary: true,
			});
		}
		let text = String::from_utf8(output.stdout)
			.map_err(|e| CoderError::tool_failed("read_file", format!("{abs_path}: invalid UTF-8: {e}")))?;
		Ok(ReadFileResult {
			text,
			mtime_ms,
			is_binary: false,
		})
	}

	/// Read an image file and return it as the runner's typed
	/// `images` convention: the text projection is a one-line
	/// note, the pixels ride alongside. `WorkspaceHost` doesn't
	/// surface binary content, so in-workspace bytes come straight
	/// from the host filesystem — valid under both routings: the
	/// folder is bind-mounted into the container at identical
	/// bytes and the file tools already run host-side per
	/// `ContainerHost`. Out-of-workspace reads mirror
	/// [`Self::oow_read_file`]'s container `cat` path.
	async fn read_image_result(
		&self,
		resolved: &ResolvedTarget,
		path: &str,
		mime: &str,
		cx: &ToolContext,
	) -> Result<Value, CoderError> {
		let bytes = match resolved {
			ResolvedTarget::InWorkspace { folder, relative } => {
				let abs = Utf8Path::new(&folder.folder.path).join(relative);
				tokio::fs::read(abs.as_std_path())
					.await
					.map_err(|err| CoderError::tool_failed("read_file", format!("{path}: {err}")))?
			}
			ResolvedTarget::OutOfWorkspace { abs_path } => {
				if self.oow_target_is_container(cx).await {
					let mut cat = self.container_exec("cat", &["--", abs_path.as_str()]).await;
					let output = run_capturing(&mut cat, "read_file").await?;
					if !output.status.success() {
						return Err(CoderError::tool_failed(
							"read_file",
							container_io_error(abs_path, &output.stderr),
						));
					}
					output.stdout
				} else {
					tokio::fs::read(abs_path.as_std_path())
						.await
						.map_err(|err| CoderError::tool_failed("read_file", format!("{path}: {err}")))?
				}
			}
		};
		if bytes.len() > IMAGE_MAX_BYTES {
			return Err(CoderError::tool_failed(
				"read_file",
				format!(
					"{path}: image is {:.1} MB, over the {:.1} MB limit",
					bytes.len() as f64 / 1_000_000.0,
					IMAGE_MAX_BYTES as f64 / 1_000_000.0
				),
			));
		}
		// The text projection reports the *file's* mime and size —
		// that's what the model asked about. Whether we re-encode for
		// transport (see `crate::images`) is our business, not
		// something the model should reason about.
		let note = format!("[image file — {mime}, {:.1} kB, attached]", bytes.len() as f64 / 1000.0);
		let mime = mime.to_string();
		let attachment = tokio::task::spawn_blocking(move || crate::images::attachment_from_bytes(&bytes, &mime))
			.await
			.map_err(|err| CoderError::tool_failed("read_file", format!("{path}: image encoding failed: {err}")))?;
		Ok(json!({
			"path": path,
			"content": note,
			"images": [attachment],
		}))
	}

	/// Write raw bytes to an arbitrary absolute path, routed to the
	/// container when it's running and the host otherwise. No
	/// format-on-save either way — external files have no project
	/// config (see the `write_file` tool's out-of-workspace arm).
	async fn oow_write_file(
		&self,
		abs_path: &Utf8Path,
		content: &str,
		cx: &ToolContext,
	) -> Result<WriteFileResult, CoderError> {
		if !self.oow_target_is_container(cx).await {
			return Ok(moon_core::write_host_file(abs_path, content).await?);
		}
		// `cp /dev/stdin <path>` with the content piped on stdin:
		// direct exec (no shell redirection to quote), and `cp`
		// from `/dev/stdin` lands the bytes verbatim. `-i` keeps
		// stdin open for the pipe.
		let mut cmd = self
			.container_exec_stdin("cp", &["/dev/stdin", abs_path.as_str()])
			.await;
		cmd
			.stdin(std::process::Stdio::piped())
			.stdout(std::process::Stdio::piped())
			.stderr(std::process::Stdio::piped());
		let mut child = cmd
			.spawn()
			.map_err(|err| CoderError::tool_failed("write_file", format!("spawn failed: {err}")))?;
		if let Some(mut stdin) = child.stdin.take() {
			use tokio::io::AsyncWriteExt as _;
			stdin
				.write_all(content.as_bytes())
				.await
				.map_err(|err| CoderError::tool_failed("write_file", format!("write to container stdin: {err}")))?;
			stdin
				.shutdown()
				.await
				.map_err(|err| CoderError::tool_failed("write_file", format!("close container stdin: {err}")))?;
		}
		let output = child
			.wait_with_output()
			.await
			.map_err(|err| CoderError::tool_failed("write_file", err.to_string()))?;
		if !output.status.success() {
			return Err(CoderError::tool_failed(
				"write_file",
				container_io_error(abs_path, &output.stderr),
			));
		}
		let mtime_ms = self.container_stat_mtime_ms(abs_path).await;
		Ok(WriteFileResult {
			mtime_ms,
			bytes_written: content.len() as u64,
		})
	}

	/// List an arbitrary absolute directory, routed to the
	/// container when it's running and the host otherwise. Returns
	/// the same [`DirEntry`] shape `read_dir` produces (kind + name;
	/// `path` carries the absolute child path so the model can feed
	/// it straight back into another tool call).
	async fn oow_read_dir(&self, abs_path: &Utf8Path, cx: &ToolContext) -> Result<Vec<DirEntry>, CoderError> {
		if !self.oow_target_is_container(cx).await {
			return host_read_dir_abs(abs_path).await;
		}
		// One `find` exec lists the directory's direct children with
		// a type tag per line (`d`/`f`/`l`/`?`) so we don't need a
		// `stat` per entry. `-maxdepth 1 -mindepth 1` is direct
		// children only; `printf` keeps the framing fixed.
		let arg = format!(
			"find {} -maxdepth 1 -mindepth 1 -printf '%y\\t%f\\n' 2>&1",
			shell_single_quote(abs_path.as_str())
		);
		let mut cmd = self.container_exec("sh", &["-c", &arg]).await;
		let output = run_capturing(&mut cmd, "list_dir").await?;
		if !output.status.success() {
			return Err(CoderError::tool_failed(
				"list_dir",
				container_io_error(abs_path, &output.stdout),
			));
		}
		let text = String::from_utf8_lossy(&output.stdout);
		let mut entries = Vec::new();
		for line in text.lines() {
			let Some((tag, name)) = line.split_once('\t') else {
				continue;
			};
			if name.is_empty() || name == ".git" {
				continue;
			}
			let kind = match tag {
				"d" => EntryKind::Dir,
				"f" => EntryKind::File,
				"l" => EntryKind::Symlink,
				_ => EntryKind::Other,
			};
			entries.push(DirEntry {
				is_hidden: name.starts_with('.'),
				name: name.to_string(),
				path: abs_path.join(name).to_string(),
				kind,
				size: None,
				mtime_ms: None,
			});
		}
		entries.sort_by(|a, b| match (a.kind, b.kind) {
			(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
			(EntryKind::Dir, _) => std::cmp::Ordering::Less,
			(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
			_ => a.name.cmp(&b.name),
		});
		Ok(entries)
	}

	/// Best-effort container-side mtime (epoch ms). Used only as the
	/// advisory `mtime_ms` on read/write results, so any failure
	/// collapses to `None` rather than failing the tool.
	async fn container_stat_mtime_ms(&self, abs_path: &Utf8Path) -> Option<i64> {
		// `stat -c %Y` is seconds since epoch on GNU coreutils
		// (moon-base is Debian). Multiply to ms for parity with the
		// host path's `system_time_to_ms`.
		let mut cmd = self
			.container_exec("stat", &["-c", "%Y", "--", abs_path.as_str()])
			.await;
		let output = run_capturing(&mut cmd, "stat").await.ok()?;
		if !output.status.success() {
			return None;
		}
		let secs: i64 = String::from_utf8_lossy(&output.stdout).trim().parse().ok()?;
		Some(secs * 1000)
	}

	/// Like [`container_exec`](Self::container_exec) but adds `-i` so
	/// stdin stays open for the caller to pipe bytes through.
	async fn container_exec_stdin(&self, program: &str, args: &[&str]) -> tokio::process::Command {
		let workspace_id = self.workspaces.workspace_id().await;
		let container_name = container_name_for_workspace(&workspace_id);
		let mut command = tokio::process::Command::new("docker");
		command.arg("exec").arg("-i").arg(&container_name).arg(program);
		for arg in args {
			command.arg(arg);
		}
		command
	}
}

/// Run `command` to completion capturing stdout/stderr, mapping a
/// spawn / wait failure onto the named tool's `ToolFailed`. Caller
/// inspects `output.status` for command-level failures.
async fn run_capturing(
	command: &mut tokio::process::Command,
	tool: &'static str,
) -> Result<std::process::Output, CoderError> {
	command
		.stdin(std::process::Stdio::null())
		.stdout(std::process::Stdio::piped())
		.stderr(std::process::Stdio::piped());
	command
		.output()
		.await
		.map_err(|err| CoderError::tool_failed(tool, format!("docker exec failed: {err}")))
}

/// Read an absolute host directory (out-of-workspace, no root
/// gate). Mirrors `LocalHost::read_dir`'s entry shape but without
/// the relative-path rewrite — `path` carries the absolute child
/// path so the model can feed it straight back into another tool.
async fn host_read_dir_abs(abs_path: &Utf8Path) -> Result<Vec<DirEntry>, CoderError> {
	let mut read = tokio::fs::read_dir(abs_path.as_std_path())
		.await
		.map_err(|err| CoderError::tool_failed("list_dir", format!("{abs_path}: {err}")))?;
	let mut entries = Vec::new();
	while let Some(entry) = read
		.next_entry()
		.await
		.map_err(|err| CoderError::tool_failed("list_dir", err.to_string()))?
	{
		let file_type = entry
			.file_type()
			.await
			.map_err(|err| CoderError::tool_failed("list_dir", err.to_string()))?;
		let kind = if file_type.is_dir() {
			EntryKind::Dir
		} else if file_type.is_symlink() {
			EntryKind::Symlink
		} else if file_type.is_file() {
			EntryKind::File
		} else {
			EntryKind::Other
		};
		let name = entry.file_name().to_string_lossy().to_string();
		if name == ".git" {
			continue;
		}
		entries.push(DirEntry {
			is_hidden: name.starts_with('.'),
			path: abs_path.join(&name).to_string(),
			name,
			kind,
			size: None,
			mtime_ms: None,
		});
	}
	entries.sort_by(|a, b| match (a.kind, b.kind) {
		(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
		(EntryKind::Dir, _) => std::cmp::Ordering::Less,
		(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
		_ => a.name.cmp(&b.name),
	});
	Ok(entries)
}

/// The image extensions `read_file` will return as pixels rather
/// than erroring on, mapped to their MIME type. Extension-based
/// (no magic-byte sniffing): the model already knows the name it
/// asked for, and a mislabeled extension is the user's own doing.
fn image_mime_for(path: &str) -> Option<&'static str> {
	let ext = Utf8Path::new(path).extension()?.to_ascii_lowercase();
	Some(match ext.as_str() {
		"png" => "image/png",
		"jpg" | "jpeg" => "image/jpeg",
		"gif" => "image/gif",
		"webp" => "image/webp",
		_ => return None,
	})
}

/// Same null-byte heuristic as `moon_core`'s `looks_binary`: a NUL
/// in the first 8 kB means binary. Duplicated here because the
/// container read path holds raw `Vec<u8>` from `docker exec`
/// stdout rather than going through `read_host_file`.
fn looks_binary_bytes(bytes: &[u8]) -> bool {
	bytes[..bytes.len().min(8000)].contains(&0)
}

/// Build a useful error string from a failed container fs op. We
/// surface the captured stderr (trimmed) when there is one, else a
/// generic "no such file or permission denied" hint keyed on the
/// path.
fn container_io_error(abs_path: &Utf8Path, stderr: &[u8]) -> String {
	let msg = String::from_utf8_lossy(stderr);
	let trimmed = msg.trim();
	if trimmed.is_empty() {
		format!("{abs_path}: no such file or directory, or permission denied (in container)")
	} else {
		format!("{abs_path}: {trimmed}")
	}
}

/// Single-quote a string for safe interpolation into an `sh -c`
/// command. Wraps in `'...'` and escapes embedded single quotes as
/// `'\''`. Used for the one place an out-of-workspace fs op needs a
/// shell (`find` for `list_dir`); the read/write paths pass the
/// path as a direct exec arg and don't need this.
fn shell_single_quote(s: &str) -> String {
	let mut out = String::with_capacity(s.len() + 2);
	out.push('\'');
	for ch in s.chars() {
		if ch == '\'' {
			out.push_str("'\\''");
		} else {
			out.push(ch);
		}
	}
	out.push('\'');
	out
}

/// Single source of truth for "should bash route through the
/// workspace shell container?". Mirrors `lsp.rs::resolve_target`
/// almost line-for-line: build a [`ContainerWorkspace`] from the
/// current bound-folder set + workspace id, ask its lifecycle
/// `status()`, and route to the container only if the project is
/// `Running`. Any failure (no compose project, daemon
/// unreachable, parse error) falls back to host — the agent's
/// bash should never become unusable just because docker
/// isn't responding.
///
/// Called from both `tools::bash` and `runner::status` so the
/// indicator pip and the actual command's `target` field can't
/// drift.
///
/// `force_host` is the per-session escape hatch
/// ([`crate::sessions::BashTargetOverride::ForceHost`]): when set,
/// short-circuit to host without probing the container at all, so
/// an agent can inspect host-side Docker / networking even while
/// the workspace runs in a container. The default (`false`) keeps
/// the historical auto behaviour.
pub(crate) async fn resolve_bash_target(
	workspaces: &WorkspaceRegistry,
	workspaces_dir: &Utf8Path,
	force_host: bool,
	folder: &WorkspaceFolderEntry,
) -> &'static str {
	if force_host {
		return BASH_TARGET_HOST;
	}
	let Some(applied) = running_container_applied_folders(workspaces, workspaces_dir).await else {
		return BASH_TARGET_HOST;
	};
	// The container only mounts the folders its compose state was
	// emitted from. A folder bound *after* the container came up
	// (coordinator `init_repo` / `clone_repo`) isn't mounted —
	// container-routed commands would land in a cwd that doesn't
	// exist. Route those to the host until the container is
	// re-synced. Worktree folders ride their parent's mount.
	let mount_root = moon_core::worktree::effective_mount_root(&folder.folder);
	if applied.iter().any(|p| p.as_str() == mount_root) {
		BASH_TARGET_CONTAINER
	} else {
		BASH_TARGET_HOST
	}
}

/// When the workspace shell container is `Running`, return the
/// bound-folder set its compose state was emitted from (the folders
/// it actually mounts). `None` when there's no running container —
/// config unavailable, status probe failed, or any state other than
/// `Running` — so callers collapse to host routing.
pub(crate) async fn running_container_applied_folders(
	workspaces: &WorkspaceRegistry,
	workspaces_dir: &Utf8Path,
) -> Option<Vec<Utf8PathBuf>> {
	let workspace_id = workspaces.workspace_id().await;
	let bound: Vec<Utf8PathBuf> = workspaces
		.folders()
		.await
		.iter()
		.map(|entry| Utf8PathBuf::from(&entry.folder.path))
		.collect();
	let ws = match ContainerWorkspace::new(WorkspaceConfig {
		workspace_id: workspace_id.clone(),
		state_dir: workspaces_dir.join(&workspace_id),
		bound_folders: bound,
	}) {
		Ok(ws) => ws,
		Err(err) => {
			tracing::debug!(%err, "coder: container config unavailable, routing to host");
			return None;
		}
	};
	match ws.status().await {
		Ok(status) if matches!(status.state, ContainerState::Running) => Some(ws.applied_bound_folders().await),
		Ok(_) => None,
		Err(err) => {
			tracing::debug!(%err, "coder: container status query failed, routing to host");
			None
		}
	}
}

/// Wire labels for the `bash` target. Frontend reads these
/// verbatim from the tool result (and from `CoderStatus`) so the
/// strings are part of the protocol — don't rename without
/// updating `src/lib/protocol.ts` in lockstep.
pub(crate) const BASH_TARGET_HOST: &str = "host";
pub(crate) const BASH_TARGET_CONTAINER: &str = "container";

/// Find every byte-offset at which `needle` appears in `haystack`.
/// Used by `edit_file` to (a) detect zero-match / multi-match cases
/// before mutating, and (b) pick the right occurrence when the LLM
/// disambiguates with `occurrence`.
///
/// Linear-scan with `str::find` advancement: O(n·m) but the inputs
/// are LLM-sized (file contents + a few hundred bytes of `find`),
/// not large-corpus. Same algorithm `pi-mono` uses for the same
/// reason.
/// Pick the bound folder whose root is an ancestor of `path` (or
/// equal to it) and return `(folder, relative_path_inside)`. When
/// multiple roots match — only possible if a bound folder is a
/// strict ancestor of another, which the file tree allows — the
/// **longest** match wins so the inner folder's relative addressing
/// stays correct. Returns `None` for absolute paths that aren't
/// under any bound root.
///
/// `path` must be absolute; callers branch on `is_absolute()` first.
/// We compare on the component-prefix (not raw-string-prefix) so a
/// folder root `/foo/bar` doesn't match an unrelated path
/// `/foo/barbaz/...`.
fn match_bound_folder_root(
	folders: &[Arc<WorkspaceFolderEntry>],
	path: &Utf8Path,
) -> Option<(Arc<WorkspaceFolderEntry>, String)> {
	let mut best: Option<(Arc<WorkspaceFolderEntry>, String, usize)> = None;
	for entry in folders {
		let root = Utf8Path::new(entry.folder.path.as_str());
		let Ok(tail) = path.strip_prefix(root) else {
			continue;
		};
		let depth = root.as_str().len();
		let relative = if tail.as_str().is_empty() {
			".".to_string()
		} else {
			tail.as_str().to_string()
		};
		match &best {
			Some((_, _, prev_depth)) if *prev_depth >= depth => {}
			_ => best = Some((entry.clone(), relative, depth)),
		}
	}
	best.map(|(f, r, _)| (f, r))
}

/// Collapse a resolution that landed on the **parent project** of the
/// context's worktree back into the worktree checkout.
///
/// A worktree-backed session owns exactly one branch: when it
/// addresses the parent project by name or absolute path (picked up
/// from the system prompt's folder list, a sub-agent's report, or
/// shell output), it means "my project" — so the read or write must
/// stay in the isolated checkout. Without this, a single
/// parent-addressed `write_file` silently lands on the main working
/// tree and defeats the isolation the worktree exists for (ADR 0040).
///
/// Paths that spell out this worktree's own `.worktrees/<slug>/…`
/// tail are stripped to worktree-relative; paths into a *different*
/// worktree under the same parent (another agent's checkout) are
/// left addressed as-is.
fn collapse_worktree_parent(
	cx: &ToolContext,
	target: Arc<WorkspaceFolderEntry>,
	relative: String,
) -> (Arc<WorkspaceFolderEntry>, String) {
	let moon_protocol::workspace::FolderOrigin::Worktree { parent_path, .. } = &cx.folder.folder.origin else {
		return (target, relative);
	};
	if target.folder.path != *parent_path {
		return (target, relative);
	}
	if Utf8Path::new(&relative).starts_with(moon_core::WORKTREES_DIR_NAME) {
		let full = Utf8Path::new(parent_path).join(&relative);
		let Ok(tail) = full.strip_prefix(cx.folder.folder.path.as_str()) else {
			return (target, relative);
		};
		let s = tail.as_str();
		let stripped = if s.is_empty() { ".".to_string() } else { s.to_string() };
		return (cx.folder.clone(), stripped);
	}
	(cx.folder.clone(), relative)
}

/// Build the "no bound folder named `<name>`" error returned by
/// [`ToolRegistry::resolve_workspace_path`] when a `/workspace/<name>/...`
/// path doesn't match any currently-bound folder. The error lists what
/// *is* bound so the model can self-correct without another guess turn.
fn unbound_folder_error(
	tool: &'static str,
	raw_path: &str,
	requested: &str,
	folders: &[Arc<WorkspaceFolderEntry>],
) -> CoderError {
	let bound = folders
		.iter()
		.map(|f| format!("`{}`", f.folder.name))
		.collect::<Vec<_>>()
		.join(", ");
	let bound_clause = if bound.is_empty() {
		"no folders are currently bound".to_string()
	} else {
		format!("currently bound: {bound}")
	};
	CoderError::tool_failed(
		tool,
		format!(
			"`{raw_path}` references a workspace `{requested}`, but no folder is bound under that name \
({bound_clause}). Use the absolute path the system prompt's \"Bound folders\" section advertises for the \
target folder, or a plain relative path to address the active folder."
		),
	)
}

fn byte_offsets_of(haystack: &str, needle: &str) -> Vec<usize> {
	if needle.is_empty() {
		return Vec::new();
	}
	let mut hits = Vec::new();
	let mut start = 0;
	while let Some(idx) = haystack[start..].find(needle) {
		let absolute = start + idx;
		hits.push(absolute);
		start = absolute + needle.len();
	}
	hits
}

/// The match `edit_file` ultimately commits to. Carries the byte
/// range to splice out, the replacement text (already re-indented if
/// the fuzzy path adjusted it), and metadata for the JSON result.
#[cfg_attr(test, derive(Debug))]
struct EditPlan {
	start: usize,
	end: usize,
	replace_text: String,
	total_matches: usize,
	occurrence: usize,
	/// Which matcher succeeded. Surfaced verbatim in the tool result
	/// so the model — and any human reading the session log — can see
	/// when a fallback kicked in. The strings are part of the tool
	/// protocol: `"exact"`, `"fuzzy_unescape"`, `"fuzzy_backslash"`,
	/// `"fuzzy_indent"`, `"fuzzy_whitespace"`.
	mode: &'static str,
}

/// Locate `find` in `text` using a layered match. Returns the byte
/// range to splice plus the replacement bytes to write back.
///
/// 1. **Exact** — `str::find` against the file verbatim. Same
///    behaviour `edit_file` has always had; covers every case where
///    the model gets `find` byte-perfect on the first try.
/// 2. **Unescape fallback** — if `find` contains the literal 2-char
///    sequences `\\n` / `\\t` (the model's escape-leakage failure
///    mode) and the unescaped form matches exactly while the
///    original doesn't, treat that as the intended pattern.
///    `replace` is unescaped in the same way so the splice is
///    consistent.
/// 3. **Backslash-run-collapsing fallback** — the model can't keep
///    backslash counts straight across the JSON ⇄ source-string
///    boundary, especially in regex literals and template-literal
///    `RegExp(...)` patterns. Collapse every run of consecutive
///    backslashes to a single backslash on both `find` and the
///    file, then re-match. On a unique hit, splice the file's
///    *original* byte range (so the file's escaping survives) and
///    drop `replace` in verbatim — the model owns its own
///    replacement bytes here, same as the whitespace stage below.
/// 4. **Indent-tolerant fallback** — strip per-line leading whitespace
///    from both `find` and the file's lines, look for a line-aligned
///    match. On success, splice the *original* file lines' byte range
///    and re-indent `replace` so its first non-blank line lines up
///    with the file's match indent. This catches the "model is off
///    by one tab depth" failure mode without weakening exact-match
///    semantics — only kicks in when the strict match misses.
/// 5. **Whitespace-collapsing fallback** — last resort for the
///    "prettier rewrote it" failure mode: collapse every run of ASCII
///    whitespace (space, tab, CR, LF) to a single space on both
///    `find` and the file, anchor the comparison on `find.trim()`,
///    and locate the match in normalised space. On success, splice
///    the *original* file byte range that produced the matched
///    normalised window and drop `replace` in verbatim. Catches the
///    cases the indent stage can't — multi-space runs collapsed to
///    one, line breaks rewritten as spaces (or vice-versa), trailing
///    whitespace differences — without baking any per-formatter
///    rules in.
///
/// The fuzzy paths assume format-on-save runs after the splice and
/// will catch any residual whitespace / indentation skew. Without a
/// formatter the edit may land with the model's exact bytes dropped
/// into a slightly different file shape; that's the deliberate trade
/// — fewer "find not found" loops at the cost of a one-off
/// re-format the formatter will normalise anyway.
fn locate_edit(
	text: &str,
	find: &str,
	replace: &str,
	occurrence: Option<usize>,
	path_for_error: &str,
) -> Result<EditPlan, CoderError> {
	// Stage 1: exact match. Hot path; returns immediately on success.
	let exact = byte_offsets_of(text, find);
	if !exact.is_empty() {
		let (idx, picked) = select_match(&exact, occurrence, path_for_error, find.len(), text)?;
		return Ok(EditPlan {
			start: idx,
			end: idx + find.len(),
			replace_text: replace.to_owned(),
			total_matches: exact.len(),
			occurrence: picked,
			mode: "exact",
		});
	}

	// Stage 2: escape-leakage. Only kicks in when `find` actually
	// contains a literal `\n` / `\t` pair — otherwise the unescape is
	// a no-op and we'd just retry the same query.
	if has_literal_escape(find) {
		let unescaped_find = unescape_literals(find);
		let m = byte_offsets_of(text, &unescaped_find);
		if !m.is_empty() {
			let unescaped_replace = unescape_literals(replace);
			let (idx, picked) = select_match(&m, occurrence, path_for_error, unescaped_find.len(), text)?;
			return Ok(EditPlan {
				start: idx,
				end: idx + unescaped_find.len(),
				replace_text: unescaped_replace,
				total_matches: m.len(),
				occurrence: picked,
				mode: "fuzzy_unescape",
			});
		}
	}

	// Stage 3: backslash-run-collapsing. The model regularly sends
	// a `find` whose backslash count is off by one — `\w` vs `\\w`
	// in a template-literal `RegExp(...)`, `\\$&` vs `\\\\$&` in a
	// JS string, etc. Normalise every run of consecutive `\` bytes
	// to a single `\` on both sides, then re-search. Splice the
	// file's original byte range so the file's own escaping
	// survives the edit; `replace` is the model's responsibility,
	// same as the whitespace stage.
	let bs_hits = find_backslash_collapsed(text, find);
	if !bs_hits.is_empty() {
		let (chosen, picked) = select_bs(&bs_hits, occurrence, path_for_error, text)?;
		return Ok(EditPlan {
			start: chosen.start,
			end: chosen.end,
			replace_text: replace.to_owned(),
			total_matches: bs_hits.len(),
			occurrence: picked,
			mode: "fuzzy_backslash",
		});
	}

	// Stage 4: per-line indent-tolerant. Only well-defined when
	// `find` is line-aligned (every line is on its own); a mid-line
	// `find` falls through to the next stage.
	let fuzzy = find_indent_tolerant(text, find);
	if !fuzzy.is_empty() {
		let (chosen, picked) = select_fuzzy(&fuzzy, occurrence, path_for_error, text)?;
		let replace_text = reindent_replacement(replace, &chosen.find_indent, &chosen.file_indent);
		return Ok(EditPlan {
			start: chosen.start,
			end: chosen.end,
			replace_text,
			total_matches: fuzzy.len(),
			occurrence: picked,
			mode: "fuzzy_indent",
		});
	}

	// Stage 5: whitespace-collapsing. Normalises every run of ASCII
	// whitespace to a single space on both sides, then anchors the
	// match on `find.trim()` so we don't accidentally swallow file
	// whitespace at the splice boundary. Last resort for cases where
	// the file's shape no longer matches the model's — formatter
	// rewrote a multi-line call into one line, split a long string
	// across lines, collapsed indentation, etc.
	let ws_hits = find_whitespace_collapsed(text, find);
	if !ws_hits.is_empty() {
		let (chosen, picked) = select_ws(&ws_hits, occurrence, path_for_error, text)?;
		return Ok(EditPlan {
			start: chosen.start,
			end: chosen.end,
			replace_text: replace.to_owned(),
			total_matches: ws_hits.len(),
			occurrence: picked,
			mode: "fuzzy_whitespace",
		});
	}

	Err(CoderError::tool_failed(
		"edit_file",
		format!(
			"`find` not found in {path_for_error}. The file's bytes did not match `find` exactly, no \
indent-tolerant match was found, and no whitespace-collapsing match was found either. Re-run \
`read_file` to see the current state of the file and pass `find` with content that actually appears \
in it (whitespace differences are tolerated; missing or different non-whitespace characters are not)."
		),
	))
}

/// Resolve the index of the match to commit to, given the full list
/// of byte offsets and the caller's optional `occurrence` selector.
/// Returns `(byte_offset, 1-based-occurrence)`.
///
/// `find_len` and `text` aren't used by the picker today but are
/// kept in the signature for symmetry with `select_fuzzy` (which
/// needs `text` to format line numbers in the multi-match error).
fn select_match(
	matches: &[usize],
	occurrence: Option<usize>,
	path: &str,
	_find_len: usize,
	text: &str,
) -> Result<(usize, usize), CoderError> {
	match (matches.len(), occurrence) {
		(0, _) => unreachable!("select_match called with empty matches"),
		(1, None | Some(1)) => Ok((matches[0], 1)),
		(n, None) => {
			let lines: Vec<u32> = matches.iter().map(|&off| line_number_at_byte(text, off)).collect();
			let lines_csv = lines.iter().map(u32::to_string).collect::<Vec<_>>().join(", ");
			Err(CoderError::tool_failed(
				"edit_file",
				format!(
					"`find` matched {n} times in {path} (at lines {lines_csv}); pass `occurrence` \
(1-based) or include more surrounding context"
				),
			))
		}
		(n, Some(idx)) if idx == 0 || idx > n => Err(CoderError::tool_failed(
			"edit_file",
			format!("occurrence {idx} out of range — `find` matched {n} times"),
		)),
		// `idx >= 1` and `idx <= n` here, so the subtraction can't
		// underflow. `matches[idx - 1]` is always in bounds.
		(_, Some(idx)) => Ok((matches[idx - 1], idx)),
	}
}

fn select_fuzzy<'a>(
	matches: &'a [FuzzyMatch],
	occurrence: Option<usize>,
	path: &str,
	text: &str,
) -> Result<(&'a FuzzyMatch, usize), CoderError> {
	match (matches.len(), occurrence) {
		(0, _) => unreachable!("select_fuzzy called with empty matches"),
		(1, None | Some(1)) => Ok((&matches[0], 1)),
		(n, None) => {
			let lines: Vec<u32> = matches.iter().map(|m| line_number_at_byte(text, m.start)).collect();
			let lines_csv = lines.iter().map(u32::to_string).collect::<Vec<_>>().join(", ");
			Err(CoderError::tool_failed(
				"edit_file",
				format!(
					"`find` indent-tolerant match was ambiguous in {path} ({n} hits at lines \
{lines_csv}); pass `occurrence` (1-based) or include more surrounding context"
				),
			))
		}
		(n, Some(idx)) if idx == 0 || idx > n => Err(CoderError::tool_failed(
			"edit_file",
			format!("occurrence {idx} out of range — `find` matched {n} times"),
		)),
		(_, Some(idx)) => Ok((&matches[idx - 1], idx)),
	}
}

fn line_number_at_byte(text: &str, offset: usize) -> u32 {
	// 1-based line count: bytes preceding `offset` plus one for the
	// line we're sitting on. `bytecount`-free implementation; the
	// inputs here are LLM-call-sized (file ≤ ~200 KB) so the scan
	// cost is irrelevant.
	let upto = offset.min(text.len());
	(text[..upto].bytes().filter(|&b| b == b'\n').count() + 1) as u32
}

/// True when `find` contains a literal two-character `\n` or `\t`
/// sequence (backslash + letter) that an LLM might have meant as the
/// control character. Cheap pre-check so the unescape stage only
/// runs when there's actually something to unescape.
fn has_literal_escape(find: &str) -> bool {
	let bytes = find.as_bytes();
	let mut i = 0;
	while i + 1 < bytes.len() {
		if bytes[i] == b'\\' && (bytes[i + 1] == b'n' || bytes[i + 1] == b't') {
			return true;
		}
		i += 1;
	}
	false
}

/// Translate literal `\n` / `\t` 2-char sequences into the
/// corresponding control characters. We intentionally do **not**
/// touch `\\` — the model rarely means to embed a literal
/// backslash in `find` (real backslashes don't survive its own
/// thought-to-JSON pipeline as `\\\\`), and translating it would
/// confuse the rare case of someone editing a regex / printf
/// string. Backslash-count mismatches have their own dedicated
/// stage — see [`find_backslash_collapsed`].
fn unescape_literals(s: &str) -> String {
	let mut out = String::with_capacity(s.len());
	let mut chars = s.chars().peekable();
	while let Some(c) = chars.next() {
		if c == '\\' {
			match chars.peek() {
				Some('n') => {
					chars.next();
					out.push('\n');
					continue;
				}
				Some('t') => {
					chars.next();
					out.push('\t');
					continue;
				}
				_ => {}
			}
		}
		out.push(c);
	}
	out
}

/// A single backslash-collapsing hit. `start..end` is the byte range
/// in the original file that the splice will replace; we don't carry
/// any extra context because the replacement bytes come from the
/// model verbatim (same shape as [`WsMatch`]).
struct BsMatch {
	start: usize,
	end: usize,
}

/// Collapse every run of consecutive `\` bytes to a single `\` on
/// both `find` and the file, then locate the needle in the file's
/// normalised form. On a hit, the index map carries us back to the
/// original-file byte range that produced it.
///
/// Why this stage exists: the model's tool-call pipeline (thought →
/// JSON → us) regularly miscounts backslashes in regex literals,
/// template-literal `RegExp(...)` patterns, and `String.replace`
/// replacement strings. We saw a real session where the on-disk
/// `(^|[^\\w-])@${escaped}` came back as `(^|[^\w-])@${escaped}` in
/// `find` (under-escaped) and on the retry as `(^|[^\\\\w-])` (over-
/// escaped). Both attempts failed against the exact / unescape /
/// indent / whitespace stages, and the model gave up and wrote a
/// less-good edit. Collapsing runs of backslashes catches both
/// directions in one pass.
///
/// We splice the file's *original* byte range so the file's own
/// escaping survives the edit — the model owns its `replace` bytes
/// here, same trade as [`find_whitespace_collapsed`]. If `find`
/// contains no `\` at all this stage is a no-op vs. Stage 1 and
/// would just rediscover the exact match, so we early-return.
fn find_backslash_collapsed(text: &str, find: &str) -> Vec<BsMatch> {
	if !find.as_bytes().contains(&b'\\') {
		return Vec::new();
	}
	let norm_find = collapse_backslash_runs(find);
	let norm_text = normalize_backslash_runs_with_map(text);
	if norm_find.is_empty() || norm_text.text.is_empty() {
		return Vec::new();
	}
	let hits = byte_offsets_of_bytes(&norm_text.text, &norm_find);
	hits
		.into_iter()
		.map(|i| BsMatch {
			start: norm_text.orig_start[i],
			end: norm_text.orig_end[i + norm_find.len() - 1],
		})
		.collect()
}

/// Mirror of [`select_ws`] for backslash-collapsed matches. Kept
/// separate so the ambiguity error message names the right stage —
/// helps when someone is reading a session log and trying to figure
/// out which fallback misfired.
fn select_bs<'a>(
	matches: &'a [BsMatch],
	occurrence: Option<usize>,
	path: &str,
	text: &str,
) -> Result<(&'a BsMatch, usize), CoderError> {
	match (matches.len(), occurrence) {
		(0, _) => unreachable!("select_bs called with empty matches"),
		(1, None | Some(1)) => Ok((&matches[0], 1)),
		(n, None) => {
			let lines: Vec<u32> = matches.iter().map(|m| line_number_at_byte(text, m.start)).collect();
			let lines_csv = lines.iter().map(u32::to_string).collect::<Vec<_>>().join(", ");
			Err(CoderError::tool_failed(
				"edit_file",
				format!(
					"`find` backslash-collapsed match was ambiguous in {path} ({n} hits at lines \
{lines_csv}); pass `occurrence` (1-based) or include more surrounding context"
				),
			))
		}
		(n, Some(idx)) if idx == 0 || idx > n => Err(CoderError::tool_failed(
			"edit_file",
			format!("occurrence {idx} out of range — `find` matched {n} times"),
		)),
		(_, Some(idx)) => Ok((&matches[idx - 1], idx)),
	}
}

/// Per-byte mapping from the backslash-collapsed form of a string
/// back to original-file byte ranges. For each byte `i` in
/// [`NormalizedBs::text`], `orig_start[i]..orig_end[i]` is the byte
/// range in the original input that produced it — a run of N
/// backslashes collapses to a single output byte whose range spans
/// all N input bytes, so the splice picks up the file's actual
/// escaping when we map back.
struct NormalizedBs {
	text: Vec<u8>,
	orig_start: Vec<usize>,
	orig_end: Vec<usize>,
}

/// Walk `s` once, emitting every non-`\` byte verbatim and every
/// run of one-or-more `\` bytes as a single `\`, with the index map
/// pointing at the full original run. Linear in input length; we
/// touch each byte at most twice.
fn normalize_backslash_runs_with_map(s: &str) -> NormalizedBs {
	let bytes = s.as_bytes();
	let mut text = Vec::with_capacity(bytes.len());
	let mut orig_start = Vec::with_capacity(bytes.len());
	let mut orig_end = Vec::with_capacity(bytes.len());
	let mut i = 0;
	while i < bytes.len() {
		if bytes[i] == b'\\' {
			let run_start = i;
			while i < bytes.len() && bytes[i] == b'\\' {
				i += 1;
			}
			text.push(b'\\');
			orig_start.push(run_start);
			orig_end.push(i);
			continue;
		}
		text.push(bytes[i]);
		orig_start.push(i);
		orig_end.push(i + 1);
		i += 1;
	}
	NormalizedBs {
		text,
		orig_start,
		orig_end,
	}
}

/// Same normalisation as [`normalize_backslash_runs_with_map`]
/// without the index map — used to build the needle once.
fn collapse_backslash_runs(s: &str) -> Vec<u8> {
	normalize_backslash_runs_with_map(s).text
}

/// A single indent-tolerant hit. `start..end` is the byte range in
/// the original file that the splice will replace; `file_indent` is
/// the leading whitespace on the file's first matched line, and
/// `find_indent` is the corresponding leading whitespace on `find`'s
/// first non-blank line. Both indents are used to re-indent the
/// caller's `replace` text.
struct FuzzyMatch {
	start: usize,
	end: usize,
	file_indent: String,
	find_indent: String,
}

/// Per-line indent-tolerant match. Splits `find` into lines, strips
/// the common leading whitespace from each non-blank line, then walks
/// the file looking for a window whose lines match after the same
/// per-line strip. The window is aligned to file line boundaries on
/// both ends so the resulting splice is itself line-aligned (which
/// keeps the re-indent of `replace` well-defined).
///
/// Single-line `find` is the easy case — match the trimmed line, take
/// the file's leading whitespace as `file_indent`, take `find`'s
/// leading whitespace as `find_indent`. Multi-line: same idea,
/// per-line, with the constraint that every non-blank line of `find`
/// matches the corresponding file line after its own leading
/// whitespace is stripped. Blank lines in `find` match any blank
/// (or whitespace-only) line in the file.
fn find_indent_tolerant(text: &str, find: &str) -> Vec<FuzzyMatch> {
	let find_lines: Vec<&str> = find.split('\n').collect();
	if find_lines.is_empty() {
		return Vec::new();
	}
	// Drop a trailing empty element from a `find` that ends with `\n`
	// so the per-line walk doesn't try to match a phantom empty line
	// past the end of the window. If `find` was just `\n` (one blank
	// line) we still leave one element so the count stays meaningful.
	let find_lines = if find_lines.len() > 1 && find_lines.last() == Some(&"") {
		&find_lines[..find_lines.len() - 1]
	} else {
		&find_lines[..]
	};
	if find_lines.is_empty() {
		return Vec::new();
	}

	// `find`'s indent is the leading whitespace of its first
	// non-blank line. Falling back to "" when every line is blank
	// (degenerate `find`, but we shouldn't crash on it).
	let find_indent = find_lines
		.iter()
		.find(|l| !l.trim().is_empty())
		.map(|l| leading_whitespace(l).to_owned())
		.unwrap_or_default();

	// Pre-compute file line spans (start byte, end-without-newline
	// byte) so the inner loop can splice without re-scanning.
	let file_lines = collect_line_spans(text);
	if file_lines.len() < find_lines.len() {
		return Vec::new();
	}

	let mut hits = Vec::new();
	let limit = file_lines.len() - find_lines.len() + 1;
	for start_idx in 0..limit {
		let Some(file_indent) = file_indent_at(text, &file_lines[start_idx], find_lines[0]) else {
			continue;
		};
		let mut ok = true;
		for (offset, find_line) in find_lines.iter().enumerate() {
			let file_line = &text[file_lines[start_idx + offset].0..file_lines[start_idx + offset].1];
			if !lines_match_after_dedent(file_line, find_line) {
				ok = false;
				break;
			}
		}
		if !ok {
			continue;
		}
		let span_start = file_lines[start_idx].0;
		let span_end = file_lines[start_idx + find_lines.len() - 1].1;
		hits.push(FuzzyMatch {
			start: span_start,
			end: span_end,
			file_indent,
			find_indent: find_indent.clone(),
		});
	}
	hits
}

/// Indent of the file's first matched line, *or* `None` when the
/// model's first `find` line is blank (in which case we don't have a
/// meaningful anchor; the next iteration will try the line below).
/// Also bails when the file line is shorter than the model's
/// expected post-dedent content — a cheap pre-filter before the full
/// `lines_match_after_dedent` runs.
fn file_indent_at(text: &str, span: &(usize, usize), first_find_line: &str) -> Option<String> {
	let file_line = &text[span.0..span.1];
	let trimmed_find = first_find_line.trim_start_matches([' ', '\t']);
	if trimmed_find.is_empty() {
		return None;
	}
	let trimmed_file = file_line.trim_start_matches([' ', '\t']);
	if trimmed_file.is_empty() {
		return None;
	}
	if !trimmed_file.starts_with(trimmed_find) && trimmed_file != trimmed_find.trim_end() {
		return None;
	}
	Some(leading_whitespace(file_line).to_owned())
}

fn lines_match_after_dedent(file_line: &str, find_line: &str) -> bool {
	let f = file_line.trim_start_matches([' ', '\t']).trim_end_matches([' ', '\t']);
	let n = find_line.trim_start_matches([' ', '\t']).trim_end_matches([' ', '\t']);
	if n.is_empty() {
		// Blank `find` line matches any blank or whitespace-only
		// file line. (Both halves are empty after the trim_*.)
		return f.is_empty();
	}
	f == n
}

fn leading_whitespace(line: &str) -> &str {
	let end = line.bytes().position(|b| b != b' ' && b != b'\t').unwrap_or(line.len());
	&line[..end]
}

/// `(start, end_without_newline)` byte offsets for every line in
/// `text`, including a trailing empty line when the file ends with
/// `\n` (so the count matches `text.lines().count() + 1` for
/// newline-terminated files). The "end without newline" form is what
/// the per-line splice wants: the trailing `\n` lives outside the
/// matched span so the file's existing line structure is preserved.
fn collect_line_spans(text: &str) -> Vec<(usize, usize)> {
	let mut spans = Vec::new();
	let bytes = text.as_bytes();
	let mut line_start = 0;
	for (i, &b) in bytes.iter().enumerate() {
		if b == b'\n' {
			spans.push((line_start, i));
			line_start = i + 1;
		}
	}
	if line_start < bytes.len() {
		spans.push((line_start, bytes.len()));
	}
	spans
}

/// Apply the file-vs-find indent delta to `replace` so the spliced
/// bytes line up with the file's indentation at the match point.
///
/// - `file_indent` longer than `find_indent` and starts with it →
///   the model under-indented; prepend the extra prefix to every
///   non-blank line of `replace`.
/// - `find_indent` longer than `file_indent` and starts with it →
///   the model over-indented; strip the extra prefix from every
///   non-blank line of `replace` that has it. Lines without that
///   prefix are left as-is (defensive — the model can stay
///   internally consistent if it deepens nesting inside `replace`).
/// - Indents differ in *shape* (tab vs. spaces, mixed): leave
///   `replace` alone. Better to let format-on-save sort it than to
///   silently mutate the model's text on a guess.
fn reindent_replacement(replace: &str, find_indent: &str, file_indent: &str) -> String {
	if find_indent == file_indent {
		return replace.to_owned();
	}
	if let Some(extra) = file_indent.strip_prefix(find_indent) {
		return prepend_per_nonblank_line(replace, extra);
	}
	if let Some(extra) = find_indent.strip_prefix(file_indent) {
		return strip_per_nonblank_line(replace, extra);
	}
	replace.to_owned()
}

fn prepend_per_nonblank_line(text: &str, prefix: &str) -> String {
	if prefix.is_empty() {
		return text.to_owned();
	}
	let mut out = String::with_capacity(text.len() + prefix.len() * 4);
	for (i, line) in text.split_inclusive('\n').enumerate() {
		// First line always gets the prefix when non-blank — the
		// caller's invariant is that `replace`'s first content line
		// corresponds to the file's matched first line.
		let trimmed = line.trim_end_matches('\n');
		if trimmed.is_empty() {
			out.push_str(line);
			continue;
		}
		let _ = i; // suppress unused warning; kept for future per-line policy hooks.
		out.push_str(prefix);
		out.push_str(line);
	}
	out
}

fn strip_per_nonblank_line(text: &str, prefix: &str) -> String {
	if prefix.is_empty() {
		return text.to_owned();
	}
	let mut out = String::with_capacity(text.len());
	for line in text.split_inclusive('\n') {
		let trimmed = line.trim_end_matches('\n');
		if trimmed.is_empty() || !trimmed.starts_with(prefix) {
			out.push_str(line);
			continue;
		}
		out.push_str(&line[prefix.len()..]);
	}
	out
}

/// A single whitespace-collapsed hit. `start..end` is the byte range
/// in the *original* file that the splice replaces — covers exactly
/// the content the trimmed/normalised `find` anchored against,
/// including any internal whitespace runs that got collapsed to a
/// single space for matching. No re-indent of `replace`; format-on-save
/// is expected to reshape the spliced text.
struct WsMatch {
	start: usize,
	end: usize,
}

/// Whitespace-collapsing match. Normalises every run of ASCII
/// whitespace (` `, `\t`, `\r`, `\n`) to a single space on both
/// `find` and the file, then trims `find` so leading/trailing
/// whitespace in the model's input doesn't force the splice to
/// swallow file whitespace at the boundary. Returns the list of
/// non-overlapping hits as original-file byte ranges.
///
/// Catches the cases the indent-tolerant stage can't: formatters
/// (prettier, oxfmt, black) often rewrite multi-line function calls
/// into one line or split a long string across several. After such a
/// rewrite the model's earlier `find` no longer line-aligns, but the
/// content it cares about is still there in the file — just spaced
/// differently. This stage anchors on content, not shape.
fn find_whitespace_collapsed(text: &str, find: &str) -> Vec<WsMatch> {
	let norm_find = collapse_ws(find);
	let norm_text = normalize_ws_with_map(text);
	if norm_find.is_empty() || norm_text.text.is_empty() {
		return Vec::new();
	}
	let hits = byte_offsets_of_bytes(&norm_text.text, &norm_find);
	hits
		.into_iter()
		.map(|i| WsMatch {
			start: norm_text.orig_start[i],
			end: norm_text.orig_end[i + norm_find.len() - 1],
		})
		.collect()
}

fn select_ws<'a>(
	matches: &'a [WsMatch],
	occurrence: Option<usize>,
	path: &str,
	text: &str,
) -> Result<(&'a WsMatch, usize), CoderError> {
	match (matches.len(), occurrence) {
		(0, _) => unreachable!("select_ws called with empty matches"),
		(1, None | Some(1)) => Ok((&matches[0], 1)),
		(n, None) => {
			let lines: Vec<u32> = matches.iter().map(|m| line_number_at_byte(text, m.start)).collect();
			let lines_csv = lines.iter().map(u32::to_string).collect::<Vec<_>>().join(", ");
			Err(CoderError::tool_failed(
				"edit_file",
				format!(
					"`find` whitespace-collapsed match was ambiguous in {path} ({n} hits at lines \
{lines_csv}); pass `occurrence` (1-based) or include more surrounding context"
				),
			))
		}
		(n, Some(idx)) if idx == 0 || idx > n => Err(CoderError::tool_failed(
			"edit_file",
			format!("occurrence {idx} out of range — `find` matched {n} times"),
		)),
		(_, Some(idx)) => Ok((&matches[idx - 1], idx)),
	}
}

fn is_ascii_ws_byte(b: u8) -> bool {
	matches!(b, b' ' | b'\t' | b'\r' | b'\n')
}

/// Normalise a UTF-8 string for whitespace-tolerant matching.
///
/// Walks the bytes and rewrites runs of ASCII whitespace using a
/// context-sensitive rule:
///
/// - Whitespace **between two word bytes** (`[A-Za-z0-9_]`) collapses
///   to a single `' '`. This keeps `let foo` from matching `letfoo`
///   — losing the space would turn separate identifiers into one.
/// - Whitespace **adjacent to a non-word byte** on either side
///   (punctuation like `(`, `)`, `,`, `=`, …, or the start/end of the
///   string) is dropped entirely. Prettier-style reformatting moves
///   freely around punctuation, so `foo( a, b )` and `foo(a,b,)` and
///   `foo(\n\ta,\n\tb,\n)` all normalise to the same `foo(a,b,)`.
///
/// Bytes are emitted as `Vec<u8>` rather than `String` so multi-byte
/// UTF-8 continuation bytes round-trip without re-encoding (and so
/// the per-byte index map stays well-defined). All non-ASCII bytes
/// are treated as non-word, which is the safe default — non-ASCII
/// identifiers exist but they're rare and never collide with the
/// ASCII-only word-boundary heuristic.
fn normalize_ws_with_map(s: &str) -> NormalizedWs {
	let bytes = s.as_bytes();
	let mut text = Vec::with_capacity(bytes.len());
	let mut orig_start = Vec::with_capacity(bytes.len());
	let mut orig_end = Vec::with_capacity(bytes.len());
	let mut i = 0;
	let mut last_non_ws: Option<u8> = None;
	while i < bytes.len() {
		if is_ascii_ws_byte(bytes[i]) {
			let run_start = i;
			while i < bytes.len() && is_ascii_ws_byte(bytes[i]) {
				i += 1;
			}
			let next = bytes.get(i).copied();
			let keep = matches!(last_non_ws, Some(b) if is_word_byte(b)) && matches!(next, Some(b) if is_word_byte(b));
			if keep {
				text.push(b' ');
				orig_start.push(run_start);
				orig_end.push(i);
			}
			continue;
		}
		text.push(bytes[i]);
		orig_start.push(i);
		orig_end.push(i + 1);
		last_non_ws = Some(bytes[i]);
		i += 1;
	}
	NormalizedWs {
		text,
		orig_start,
		orig_end,
	}
}

/// The normalised form of a source text plus the per-byte mapping
/// back to original byte ranges. For each byte `i` in
/// [`NormalizedWs::text`], `orig_start[i]..orig_end[i]` is the byte
/// range in the original input that produced it. See
/// [`normalize_ws_with_map`] for the normalisation rule.
struct NormalizedWs {
	text: Vec<u8>,
	orig_start: Vec<usize>,
	orig_end: Vec<usize>,
}

/// Same context-sensitive normalisation as
/// [`normalize_ws_with_map`] but without the index map — used to
/// build the needle once for the search. Keeping a single source of
/// truth for the rule prevents the two from drifting.
fn collapse_ws(s: &str) -> Vec<u8> {
	normalize_ws_with_map(s).text
}

fn is_word_byte(b: u8) -> bool {
	b.is_ascii_alphanumeric() || b == b'_'
}

/// Byte-level substring search. Same `+= needle.len()` advancement
/// `byte_offsets_of` uses; needed here because the normalised text
/// is `Vec<u8>` rather than `&str` (see [`NormalizedWs`] for why).
fn byte_offsets_of_bytes(haystack: &[u8], needle: &[u8]) -> Vec<usize> {
	if needle.is_empty() || haystack.len() < needle.len() {
		return Vec::new();
	}
	let mut hits = Vec::new();
	let mut start = 0;
	while start + needle.len() <= haystack.len() {
		if &haystack[start..start + needle.len()] == needle {
			hits.push(start);
			start += needle.len();
		} else {
			start += 1;
		}
	}
	hits
}

fn clamp_to_char_boundary(s: &str, max: usize) -> usize {
	if s.len() <= max {
		return s.len();
	}
	let mut idx = max;
	while idx > 0 && !s.is_char_boundary(idx) {
		idx -= 1;
	}
	idx
}

/// Render `text` as `<line_no>|<line>` pairs, restricted to the
/// 1-based inclusive range `[start_line, end_line]`. Out-of-range
/// `end_line` values clamp silently to EOF; an empty file yields an
/// empty string. The byte cap [`READ_FILE_MAX_BYTES`] applies to
/// the rendered string — long ranges that go past it are cut at a
/// char boundary and `truncated == true` is returned so the agent
/// can ask for a smaller window.
fn format_numbered_lines(text: &str, start_line: u32, end_line: u32) -> (String, bool) {
	use std::fmt::Write as _;
	if text.is_empty() {
		return (String::new(), false);
	}
	let total: u32 = text.lines().count() as u32;
	if total == 0 || start_line > total {
		return (String::new(), false);
	}
	let effective_end = end_line.min(total);
	// Right-align line numbers to the width of the largest one in
	// the rendered range. Keeps narrow files narrow and widens up
	// to whatever the file actually needs for big ones.
	let width = digit_width(effective_end) as usize;
	let mut out = String::new();
	let mut truncated = false;
	for (idx, line) in text.lines().enumerate() {
		let line_no = (idx + 1) as u32;
		if line_no < start_line {
			continue;
		}
		if line_no > effective_end {
			break;
		}
		let _ = writeln!(out, "{line_no:>width$}|{line}");
		if out.len() > READ_FILE_MAX_BYTES {
			let cut = clamp_to_char_boundary(&out, READ_FILE_MAX_BYTES);
			out.truncate(cut);
			truncated = true;
			break;
		}
	}
	(out, truncated)
}

fn digit_width(mut n: u32) -> u32 {
	if n == 0 {
		return 1;
	}
	let mut w = 0;
	while n > 0 {
		n /= 10;
		w += 1;
	}
	w
}

/// Hard cap on the number of UTF-8 characters of a matched line we'll dump
/// into the model's context. A single base64-embedded image, minified JS
/// bundle, or pretty-printed JSON blob can produce a "line" that's tens of
/// kilobytes long; without this cap a single `grep` call could blow the
/// context window. 500 chars is generous for normal code (well past
/// rustfmt's 100-column default and prettier's 80) while keeping a hit on
/// an inlined base64 payload to a stub the model can still act on (the
/// `path:line` is intact, so a follow-up `read_file` with `start_line` /
/// `end_line` is the natural escape hatch).
const GREP_MAX_LINE_CHARS: usize = 500;

fn format_grep_hits(hits: &[ContentSearchHit]) -> String {
	let mut out = String::new();
	for hit in hits {
		// `path:line: line_text` — same shape as `grep -n`. Trim the
		// matched line to keep individual hits short; the model still
		// gets enough surrounding context to decide whether to read
		// the file.
		let trimmed = hit.line_text.trim_end_matches('\n');
		out.push_str(&hit.path);
		out.push(':');
		out.push_str(&hit.line.to_string());
		out.push_str(": ");
		out.push_str(&truncate_grep_line(trimmed));
		out.push('\n');
	}
	out
}

/// Cap a matched line at [`GREP_MAX_LINE_CHARS`] UTF-8 characters. Lines
/// at or under the cap pass through unchanged; longer lines get the cap
/// prefix plus a `[…line truncated, N chars total]` marker so the model
/// can see the line is huge and reach for `read_file` if it needs the
/// rest. Counting in characters (not bytes) keeps multi-byte UTF-8 from
/// landing the slice mid-codepoint.
fn truncate_grep_line(line: &str) -> std::borrow::Cow<'_, str> {
	let mut count = 0usize;
	let mut cut_byte = None;
	for (idx, _) in line.char_indices() {
		if count == GREP_MAX_LINE_CHARS {
			cut_byte = Some(idx);
			break;
		}
		count += 1;
	}
	match cut_byte {
		None => std::borrow::Cow::Borrowed(line),
		Some(cut) => {
			let total = count + line[cut..].chars().count();
			std::borrow::Cow::Owned(format!("{}… [line truncated, {total} chars total]", &line[..cut]))
		}
	}
}

fn truncate_bytes(bytes: &[u8], max: usize) -> String {
	if bytes.len() <= max {
		return String::from_utf8_lossy(bytes).into_owned();
	}
	let cut = clamp_to_char_boundary_bytes(bytes, max);
	let mut s = String::from_utf8_lossy(&bytes[..cut]).into_owned();
	s.push_str("\n[...output truncated]");
	s
}

fn clamp_to_char_boundary_bytes(bytes: &[u8], max: usize) -> usize {
	if bytes.len() <= max {
		return bytes.len();
	}
	let mut idx = max;
	while idx > 0 {
		match std::str::from_utf8(&bytes[..idx]) {
			Ok(_) => return idx,
			Err(_) => idx -= 1,
		}
	}
	0
}

#[cfg(test)]
mod tests {
	use super::{
		byte_offsets_of, format_grep_hits, format_numbered_lines, locate_edit, truncate_grep_line, GREP_MAX_LINE_CHARS,
	};
	use moon_protocol::search::ContentSearchHit;

	#[test]
	fn locate_edit_exact_match_returns_byte_range_and_replacement() {
		// Hot path: model's `find` byte-matches the file. Splice
		// covers exactly `find.len()` bytes; `replace_text` is
		// returned verbatim so existing tests / callers keep their
		// invariants.
		let text = "alpha\nbeta\ngamma\n";
		let plan = locate_edit(text, "beta", "BETA", None, "test.txt").expect("exact match");
		assert_eq!(plan.start, 6);
		assert_eq!(plan.end, 10);
		assert_eq!(plan.replace_text, "BETA");
		assert_eq!(plan.mode, "exact");
		assert_eq!(plan.total_matches, 1);
		assert_eq!(plan.occurrence, 1);
	}

	#[test]
	fn locate_edit_indent_fallback_prepends_missing_tabs() {
		// file has 3-tab indent on the `error: {` line, model wrote `find` with
		// 2 tabs. The fuzzy match should still locate the block
		// and the spliced `replace` should pick up the extra tab
		// so the file's indentation is preserved without relying
		// on the formatter.
		let file = "fn main() {\n\t\t\terror: {\n\t\t\t\tname: x,\n\t\t\t},\n}\n";
		let find = "\t\terror: {\n\t\t\tname: x,\n\t\t},";
		let replace = "\t\terror: {\n\t\t\tname: x,\n\t\t\tstatus: 500,\n\t\t},";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("fuzzy indent match");
		assert_eq!(plan.mode, "fuzzy_indent");
		// Splice covers the file's actual matched lines (3-tab
		// indent), not the model's would-be `find` bytes.
		assert_eq!(
			&file[plan.start..plan.end],
			"\t\t\terror: {\n\t\t\t\tname: x,\n\t\t\t},"
		);
		// Replacement is shifted by the missing tab on every
		// non-blank line.
		assert_eq!(
			plan.replace_text,
			"\t\t\terror: {\n\t\t\t\tname: x,\n\t\t\t\tstatus: 500,\n\t\t\t},"
		);
	}

	#[test]
	fn locate_edit_indent_fallback_strips_extra_tabs() {
		// Mirror case: model over-indented its `find` relative to
		// the file. We strip the excess from `replace` so the
		// splice still slots in cleanly.
		let file = "error: {\n\tname: x,\n},\n";
		let find = "\t\terror: {\n\t\t\tname: x,\n\t\t},";
		let replace = "\t\terror: {\n\t\t\tname: x,\n\t\t\tstatus: 500,\n\t\t},";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("fuzzy indent strip match");
		assert_eq!(plan.mode, "fuzzy_indent");
		assert_eq!(&file[plan.start..plan.end], "error: {\n\tname: x,\n},");
		assert_eq!(plan.replace_text, "error: {\n\tname: x,\n\tstatus: 500,\n},");
	}

	#[test]
	fn locate_edit_unescape_fallback_translates_literal_tn() {
		// Escape-leakage failure mode: the model's `find` carries
		// the literal two-char `\n` / `\t` sequences instead of
		// the control bytes. The unescape stage retries with the
		// translated form and quietly succeeds.
		let file = "a\n\tb\n";
		let find = r"a\n\tb";
		let replace = r"x\n\ty";
		let plan = locate_edit(file, find, replace, None, "test.txt").expect("unescape match");
		assert_eq!(plan.mode, "fuzzy_unescape");
		assert_eq!(&file[plan.start..plan.end], "a\n\tb");
		assert_eq!(plan.replace_text, "x\n\ty");
	}

	#[test]
	fn locate_edit_unescape_fallback_preserves_multibyte_chars() {
		// Regression: the unescape stage used to walk `find` /
		// `replace` byte-by-byte and push each byte as a `char`,
		// which mangles any multi-byte UTF-8 scalar into mojibake
		// (`é` → `Ã©`). A file with non-ASCII text that misses the
		// exact match and falls through to `fuzzy_unescape` must
		// keep its accents / CJK / emoji intact.
		let file = "title: \"café ☕ 中文\"\nbody: \"olá\"\n";
		let find = "title: \"café ☕ 中文\"\\nbody: \"olá\"";
		let replace = "title: \"thé ☕ 中文\"\\nbody: \"olá\"";
		let plan = locate_edit(file, find, replace, None, "src/i18n.ts").expect("unescape match");
		assert_eq!(plan.mode, "fuzzy_unescape");
		assert_eq!(&file[plan.start..plan.end], "title: \"café ☕ 中文\"\nbody: \"olá\"");
		assert_eq!(plan.replace_text, "title: \"thé ☕ 中文\"\nbody: \"olá\"");
	}

	#[test]
	fn locate_edit_backslash_fallback_handles_under_escape() {
		// Real failure mode from a moon-coder session: the file
		// contains a template-literal RegExp with source-level
		// double-backslash escapes (`\\w`, `\\[`, `\\]`); the
		// model's `find` had single backslashes (`\w`, `\[`,
		// `\]`) because it lost a level of escaping somewhere in
		// the thought-to-JSON pipeline. Stage 3 should locate
		// the line, splice the file's original bytes, and drop
		// the model's `replace` in verbatim.
		let file = "  const tokenRe = new RegExp(`(^|[^\\\\w-])@${escaped}(?:\\\\[bot\\\\])?(?![\\\\w-])`, \"gi\");\n";
		let find = "  const tokenRe = new RegExp(`(^|[^\\w-])@${escaped}(?:\\[bot\\])?(?![\\w-])`, \"gi\");";
		let replace = "  const tokenRe = REPLACED;";
		let plan = locate_edit(file, find, replace, None, "src/github.ts").expect("backslash-collapse match");
		assert_eq!(plan.mode, "fuzzy_backslash");
		// Splice covers the file's original line bytes (preserving
		// the `\\w` double-backslashes), not the model's `\w`.
		assert_eq!(
			&file[plan.start..plan.end],
			"  const tokenRe = new RegExp(`(^|[^\\\\w-])@${escaped}(?:\\\\[bot\\\\])?(?![\\\\w-])`, \"gi\");"
		);
		assert_eq!(plan.replace_text, "  const tokenRe = REPLACED;");
	}

	#[test]
	fn locate_edit_backslash_fallback_handles_over_escape() {
		// Mirror direction: model double-escaped where the file
		// has a single backslash (`\\w` in find, `\w` on disk).
		// Same stage catches both because we collapse *runs* of
		// `\` on both sides.
		let file = "const re = /\\w+/g;\n";
		let find = "const re = /\\\\w+/g;";
		let replace = "const re = /[a-z]+/g;";
		let plan = locate_edit(file, find, replace, None, "src/foo.ts").expect("backslash-collapse match");
		assert_eq!(plan.mode, "fuzzy_backslash");
		assert_eq!(&file[plan.start..plan.end], "const re = /\\w+/g;");
		assert_eq!(plan.replace_text, "const re = /[a-z]+/g;");
	}

	#[test]
	fn locate_edit_backslash_fallback_skipped_when_find_has_no_backslash() {
		// Cheap pre-check: `find` without any `\` should never
		// engage Stage 3 — it'd just rediscover whatever Stage 1
		// already saw. Important so a legitimate "not found"
		// stays on Stage 1's exact path and surfaces the strict
		// error rather than the backslash-stage one.
		let file = "hello world\n";
		let err = locate_edit(file, "missing", "x", None, "src/foo.rs").unwrap_err();
		let msg = err.to_string();
		// Falls through every stage to the final actionable error.
		assert!(msg.contains("src/foo.rs"));
		assert!(msg.contains("indent-tolerant"));
	}

	#[test]
	fn locate_edit_backslash_fallback_yields_to_exact() {
		// Exact must always win, even when `find` contains
		// backslashes that *would* also match under collapse.
		// Otherwise Stage 3 would steal hits and silently strip
		// the model's intentional escaping in `replace`.
		let file = "let s = \"a\\nb\";\n";
		let find = "let s = \"a\\nb\";";
		let replace = "let s = REPLACED;";
		let plan = locate_edit(file, find, replace, None, "src/foo.rs").expect("exact match wins");
		assert_eq!(plan.mode, "exact");
	}

	#[test]
	fn locate_edit_multi_match_error_lists_line_numbers() {
		// Model gave a `find` that legitimately matches twice;
		// without `occurrence` we now name the lines so the model
		// can pick the right one with more context.
		let file = "foo\nbar\nfoo\nbaz\n";
		let err = locate_edit(file, "foo", "X", None, "dup.txt").unwrap_err();
		let msg = err.to_string();
		assert!(msg.contains("lines 1, 3"), "error should list line numbers, got: {msg}");
	}

	#[test]
	fn locate_edit_occurrence_picks_nth_exact_match() {
		// Sanity: the `occurrence` selector still works on the
		// exact path after the refactor.
		let file = "foo\nfoo\nfoo\n";
		let plan = locate_edit(file, "foo", "X", Some(2), "dup.txt").expect("occurrence=2");
		assert_eq!(plan.occurrence, 2);
		assert_eq!(plan.start, 4);
		assert_eq!(plan.end, 7);
	}

	#[test]
	fn locate_edit_no_match_returns_actionable_error() {
		// Final fallback: nothing matched. Error mentions the
		// path and tells the model what to do next (re-read,
		// check whitespace) rather than a bare "not found".
		let err = locate_edit("hello world\n", "missing", "x", None, "src/foo.rs").unwrap_err();
		let msg = err.to_string();
		assert!(msg.contains("src/foo.rs"));
		assert!(msg.contains("indent-tolerant"));
	}

	#[test]
	fn locate_edit_indent_fallback_ambiguous_lists_lines() {
		// Two indent-tolerant hits, no `occurrence` → error
		// names both line numbers so the model can disambiguate
		// without another round of guessing.
		let file = "\tfoo\n\t\tfoo\n";
		let err = locate_edit(file, "foo", "X", None, "dup.txt").unwrap_err();
		let msg = err.to_string();
		// Exact match would find both `foo`s anyway → this is
		// the exact-match ambiguity error, not the indent-fuzzy
		// one. The line numbers should still be listed.
		assert!(msg.contains("lines 1, 2"), "error should list line numbers, got: {msg}");
	}

	#[test]
	fn locate_edit_whitespace_fallback_collapses_multiple_spaces() {
		// Prettier collapsed a `foo(  a,  b)` style call into the
		// usual single-space convention. The model still has the
		// pre-format `find` from an earlier `read_file`; the
		// whitespace-collapse stage matches anyway and the splice
		// covers exactly the content range (no `replace` re-shape —
		// format-on-save handles that downstream).
		let file = "let x = foo(a, b, c);\n";
		let find = "foo(a,  b,  c)";
		let replace = "foo(a, b, c, d)";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("ws-collapse match");
		assert_eq!(plan.mode, "fuzzy_whitespace");
		assert_eq!(&file[plan.start..plan.end], "foo(a, b, c)");
		assert_eq!(plan.replace_text, "foo(a, b, c, d)");
	}

	#[test]
	fn locate_edit_whitespace_fallback_collapses_multi_line_call() {
		// Mirror case: the file has the multi-line version, the
		// model gives a single-line `find`. We still find it, and
		// the splice spans the whole multi-line region so the
		// replacement drops in cleanly. Format-on-save reshapes.
		let file = "let x = foo(\n\ta,\n\tb,\n\tc,\n);\n";
		let find = "foo(a, b, c,)";
		let replace = "foo(a, b, c, d,)";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("multi-line ws-collapse match");
		assert_eq!(plan.mode, "fuzzy_whitespace");
		assert_eq!(&file[plan.start..plan.end], "foo(\n\ta,\n\tb,\n\tc,\n)");
		assert_eq!(plan.replace_text, "foo(a, b, c, d,)");
	}

	#[test]
	fn locate_edit_whitespace_fallback_runs_after_indent_stage() {
		// When the indent-tolerant stage can match, *it* takes
		// precedence — `mode` is `fuzzy_indent`, not
		// `fuzzy_whitespace`. Guards the ordering invariant: indent
		// is conservative (preserves shape, re-indents `replace`),
		// whitespace-collapse is the loosest net.
		let file = "\terror: {\n\t\tname: x,\n\t},\n";
		let find = "error: {\n\tname: x,\n},";
		let replace = "error: {\n\tname: x,\n\tstatus: 500,\n},";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("indent path takes precedence");
		assert_eq!(plan.mode, "fuzzy_indent");
	}

	#[test]
	fn locate_edit_whitespace_fallback_ambiguous_lists_lines() {
		// Two whitespace-collapse hits, no `occurrence` → error
		// names both line numbers so the model can disambiguate.
		// Use a `find` that doesn't match exactly (different
		// internal spacing) so it falls through to the ws stage
		// before the multi-match check fires.
		let file = "let a = foo(x, y);\nlet b = foo(x,  y);\n";
		let find = "foo(x,   y)";
		let err = locate_edit(file, find, "X", None, "dup.rs").unwrap_err();
		let msg = err.to_string();
		assert!(
			msg.contains("whitespace-collapsed"),
			"error should mention the stage, got: {msg}"
		);
		assert!(msg.contains("lines 1, 2"), "error should list line numbers, got: {msg}");
	}

	#[test]
	fn locate_edit_whitespace_fallback_anchors_on_punctuation() {
		// Sloppy whitespace inside punctuation (`(  a , b  )`)
		// can't be reached by the indent stage (line content
		// differs after the trim) but the ws-collapse stage drops
		// whitespace adjacent to non-word characters and lands the
		// match. Leading / trailing ws on `find` doesn't push the
		// splice past the matched content — the normalisation
		// strips both because they're adjacent to the
		// start/end-of-string sentinel.
		let file = "let x = foo(a,b);\n";
		let find = "  foo(  a , b  )  ";
		let replace = "foo(a, b, c)";
		let plan = locate_edit(file, find, replace, None, "test.rs").expect("ws-collapse via punctuation");
		assert_eq!(plan.mode, "fuzzy_whitespace");
		assert_eq!(&file[plan.start..plan.end], "foo(a,b)");
		assert_eq!(plan.replace_text, "foo(a, b, c)");
	}

	#[test]
	fn collapse_ws_keeps_space_between_word_chars_only() {
		// Between word chars: collapses to one space (don't merge
		// adjacent identifiers).
		assert_eq!(super::collapse_ws("a  b\tc\nd"), b"a b c d");
		// Adjacent to punctuation / EOL: dropped entirely.
		assert_eq!(super::collapse_ws("foo( a, b )"), b"foo(a,b)");
		assert_eq!(super::collapse_ws("  hello  "), b"hello");
		// All-whitespace inputs reduce to nothing.
		assert_eq!(super::collapse_ws("   "), b"");
		assert_eq!(super::collapse_ws(""), b"");
	}

	#[test]
	fn normalize_ws_with_map_preserves_word_boundary_space() {
		// `let  x = 1;` → `let x=1;` (space between `t` and `x`
		// kept; whitespace around `=` and after `1` adjacent to
		// non-word, dropped).
		let norm = super::normalize_ws_with_map("let  x = 1;");
		assert_eq!(norm.text, b"let x=1;");
		// `l e t` map to bytes 0,1,2; the run "  " between `t`
		// and `x` (bytes 3..5) emits ' ' → orig [3, 5); `x` → 5;
		// the run " " (byte 6) around `=` is dropped; `=` → 7;
		// the run " " (byte 8) dropped; `1` → 9; `;` → 10.
		assert_eq!(norm.orig_start, vec![0, 1, 2, 3, 5, 7, 9, 10]);
		assert_eq!(norm.orig_end, vec![1, 2, 3, 5, 6, 8, 10, 11]);
	}

	#[test]
	fn normalize_ws_with_map_preserves_multibyte_utf8() {
		// `é` is two bytes (0xC3 0xA9). Both bytes count as
		// non-word and round-trip to themselves; the map is
		// byte-precise so a hit ending on `é`'s last byte still
		// lands on a valid char boundary in the original.
		let norm = super::normalize_ws_with_map("café x");
		assert_eq!(norm.text, b"caf\xC3\xA9x"); // " " before `x` is dropped — `é`'s last byte is non-word ASCII-wise.
		assert_eq!(norm.orig_start.len(), norm.text.len());
	}

	#[test]
	fn byte_offsets_of_finds_non_overlapping_hits() {
		// Two distinct hits at non-overlapping offsets — the
		// behaviour `edit_file` relies on for the multi-match
		// `occurrence` selector.
		assert_eq!(byte_offsets_of("foo bar foo", "foo"), vec![0, 8]);
	}

	#[test]
	fn byte_offsets_of_returns_empty_for_empty_needle() {
		// `edit_file` already rejects an empty `find` upstream,
		// but the helper stays safe on its own so a future caller
		// can't get an infinite loop out of it.
		assert!(byte_offsets_of("foo", "").is_empty());
	}

	#[test]
	fn byte_offsets_of_advances_past_match_no_overlap_loop() {
		// `aa` in `aaaa` — naive `start += 1` would emit 0, 1, 2.
		// Our advancement is `+= needle.len()`, so we get 0, 2.
		// `edit_file` deliberately treats overlapping matches as a
		// non-issue: real-world `find` strings aren't pathological.
		assert_eq!(byte_offsets_of("aaaa", "aa"), vec![0, 2]);
	}

	#[test]
	fn format_numbered_lines_full_file_default() {
		let (out, truncated) = format_numbered_lines("alpha\nbeta\ngamma\n", 1, u32::MAX);
		assert!(!truncated);
		assert_eq!(out, "1|alpha\n2|beta\n3|gamma\n");
	}

	#[test]
	fn format_numbered_lines_slice() {
		let (out, _) = format_numbered_lines("a\nb\nc\nd\ne\n", 2, 4);
		assert_eq!(out, "2|b\n3|c\n4|d\n");
	}

	#[test]
	fn format_numbered_lines_clamps_end_past_eof() {
		let (out, _) = format_numbered_lines("only\n", 1, 99);
		assert_eq!(out, "1|only\n");
	}

	#[test]
	fn format_numbered_lines_pads_width_to_largest_in_range() {
		// Range ends at line 12 → width 2 for every printed line,
		// even the single-digit ones.
		let text: String = (1..=15).map(|i| format!("L{i}\n")).collect();
		let (out, _) = format_numbered_lines(&text, 8, 12);
		let first = out.lines().next().unwrap();
		assert_eq!(first, " 8|L8");
	}

	#[test]
	fn format_numbered_lines_empty_file() {
		let (out, truncated) = format_numbered_lines("", 1, 10);
		assert_eq!(out, "");
		assert!(!truncated);
	}

	#[test]
	fn format_numbered_lines_start_past_eof_is_empty() {
		let (out, _) = format_numbered_lines("a\nb\n", 5, 10);
		assert_eq!(out, "");
	}

	fn hit(path: &str, line: u64, line_text: &str) -> ContentSearchHit {
		ContentSearchHit {
			path: path.to_owned(),
			line,
			column: 1,
			line_text: line_text.to_owned(),
			match_start: 0,
			match_end: 0,
		}
	}

	#[test]
	fn truncate_grep_line_passes_short_lines_through() {
		let line = "fn foo() { todo!() }";
		let out = truncate_grep_line(line);
		assert_eq!(out.as_ref(), line);
	}

	#[test]
	fn truncate_grep_line_caps_at_char_boundary() {
		// Build a line longer than the cap with a multi-byte char near
		// the boundary so a naive byte slice would land mid-codepoint.
		let mut line = String::new();
		for _ in 0..GREP_MAX_LINE_CHARS - 1 {
			line.push('a');
		}
		// Push a 2-byte char at exactly the cap, then more content past it.
		line.push('é');
		for _ in 0..50 {
			line.push('b');
		}
		let out = truncate_grep_line(&line);
		assert!(out.starts_with("aaa"));
		assert!(out.contains("[line truncated"));
		assert!(out.contains(&format!("{} chars total", line.chars().count())));
		assert!(!out.contains('b'));
	}

	#[test]
	fn format_grep_hits_truncates_base64_blob() {
		// Simulate a base64 image embedded as one giant single-line
		// match — the regression case the cap was added for.
		let blob = "A".repeat(50_000);
		let hits = vec![hit("assets/data.json", 1, &blob)];
		let out = format_grep_hits(&hits);
		assert!(out.starts_with("assets/data.json:1: "));
		assert!(out.contains("[line truncated, 50000 chars total]"));
		// The on-the-wire payload should be ~ cap chars + a small marker —
		// nowhere near the original 50k.
		assert!(out.len() < GREP_MAX_LINE_CHARS + 200);
	}

	#[test]
	fn format_grep_hits_does_not_touch_normal_lines() {
		let hits = vec![hit("src/lib.rs", 42, "    let x = compute_thing(input);")];
		let out = format_grep_hits(&hits);
		assert_eq!(out, "src/lib.rs:42:     let x = compute_thing(input);\n");
	}

	/// The coder half of ADR 0048: what the model is offered and
	/// which terminals it can reach. Rendering itself is covered by
	/// `moon_terminal::registry`'s own tests.
	mod terminals {
		use super::super::{CoderMode, ToolContext, ToolRegistry};
		use camino::{Utf8Path, Utf8PathBuf};
		use moon_core::WorkspaceRegistry;
		use moon_terminal::{TerminalKind, TerminalRegistration, TerminalRegistry};
		use std::sync::Arc;
		use tempfile::TempDir;

		async fn harness(folder_count: usize) -> (Vec<Utf8PathBuf>, Vec<TempDir>, Arc<TerminalRegistry>, ToolRegistry) {
			let dirs: Vec<TempDir> = (0..folder_count).map(|_| TempDir::new().unwrap()).collect();
			let paths: Vec<Utf8PathBuf> = dirs
				.iter()
				.map(|d| Utf8PathBuf::from_path_buf(d.path().to_path_buf()).unwrap())
				.collect();
			let workspaces = Arc::new(WorkspaceRegistry::new("test-workspace".into()));
			for path in &paths {
				workspaces.add_folder(path.clone()).await.unwrap();
			}
			let terminals = Arc::new(TerminalRegistry::default());
			let web = crate::web::WebClient::new().expect("web client builds in tests");
			let tools = ToolRegistry::new(
				workspaces.clone(),
				Utf8PathBuf::from("/tmp"),
				web,
				terminals.clone(),
				crate::models::shared(crate::models::CoderModels::default()),
			);
			(paths, dirs, terminals, tools)
		}

		async fn cx_for(tools: &ToolRegistry, idx: usize) -> ToolContext {
			let folders = tools.workspaces.folders().await;
			ToolContext::new(folders[idx].clone(), CoderMode::Agent)
		}

		fn registration(folder: &Utf8Path) -> TerminalRegistration {
			TerminalRegistration {
				kind: TerminalKind::Container,
				cwd: "/workspace/proj".into(),
				folder: Some(folder.to_path_buf()),
				cols: 80,
				rows: 24,
			}
		}

		#[tokio::test]
		async fn tools_are_hidden_until_a_terminal_is_open() {
			let (paths, _dirs, terminals, tools) = harness(1).await;
			let cx = cx_for(&tools, 0).await;
			assert!(tools.terminal_definitions(&cx).await.is_empty());

			terminals.register("t1", registration(&paths[0])).await;
			let names: Vec<String> = tools
				.terminal_definitions(&cx)
				.await
				.into_iter()
				.map(|def| def.function.name)
				.collect();
			assert_eq!(names, vec!["list_terminals", "read_terminal"]);
		}

		/// A sibling project's terminal must not make the tools
		/// appear, or a session would be offered a surface with
		/// nothing in it for that project.
		#[tokio::test]
		async fn a_sibling_folders_terminal_does_not_advertise_the_tools() {
			let (paths, _dirs, terminals, tools) = harness(2).await;
			terminals.register("sibling", registration(&paths[1])).await;
			let cx = cx_for(&tools, 0).await;
			assert!(tools.terminal_definitions(&cx).await.is_empty());
		}

		#[tokio::test]
		async fn list_only_returns_this_projects_terminals() {
			let (paths, _dirs, terminals, tools) = harness(2).await;
			terminals.register("mine", registration(&paths[0])).await;
			terminals.register("theirs", registration(&paths[1])).await;
			let cx = cx_for(&tools, 0).await;
			let out = tools.list_terminals(&cx).await.expect("listing succeeds");
			assert_eq!(out["count"], 1);
			assert_eq!(out["terminals"][0]["id"], "mine");
		}

		#[tokio::test]
		async fn read_returns_the_rendered_output() {
			let (paths, _dirs, terminals, tools) = harness(1).await;
			terminals.register("t1", registration(&paths[0])).await;
			terminals
				.record_output("t1", b"$ bun run dev\r\nready in 312ms\r\n")
				.await;
			let cx = cx_for(&tools, 0).await;
			let out = tools
				.read_terminal(&serde_json::json!({ "id": "t1" }), &cx)
				.await
				.expect("read succeeds");
			assert_eq!(out["output"], "$ bun run dev\nready in 312ms");
			assert_eq!(out["running"], true);
			assert_eq!(out["target"], "container");
		}

		/// Another project's terminal is refused exactly like an
		/// unknown id, with this project's ids listed so the model
		/// can recover in one step.
		#[tokio::test]
		async fn read_refuses_another_projects_terminal() {
			let (paths, _dirs, terminals, tools) = harness(2).await;
			terminals.register("mine", registration(&paths[0])).await;
			terminals.register("theirs", registration(&paths[1])).await;
			let cx = cx_for(&tools, 0).await;
			let err = tools
				.read_terminal(&serde_json::json!({ "id": "theirs" }), &cx)
				.await
				.expect_err("cross-project read is refused");
			let message = err.to_string();
			assert!(message.contains("no terminal `theirs`"), "{message}");
			assert!(message.contains("mine"), "{message}");
		}
	}

	mod cross_folder {
		use super::super::{image_mime_for, CoderMode, ToolContext, ToolRegistry};
		use crate::error::CoderError;
		use camino::Utf8PathBuf;
		use moon_core::WorkspaceRegistry;
		use std::sync::Arc;
		use tempfile::TempDir;

		async fn build_registry(paths: &[&camino::Utf8Path]) -> (Arc<WorkspaceRegistry>, ToolRegistry) {
			let registry = Arc::new(WorkspaceRegistry::new("test-workspace".into()));
			for p in paths {
				registry.add_folder(p.to_path_buf()).await.unwrap();
			}
			let workspaces_dir = Utf8PathBuf::from(paths[0].parent().unwrap_or(camino::Utf8Path::new("/tmp")));
			let web = crate::web::WebClient::new().expect("web client builds in tests");
			let tool_registry = ToolRegistry::new(
				registry.clone(),
				workspaces_dir,
				web,
				Arc::default(),
				crate::models::shared(crate::models::CoderModels::default()),
			);
			(registry, tool_registry)
		}

		async fn make_cx(registry: &Arc<WorkspaceRegistry>, idx: usize) -> ToolContext {
			let folders = registry.folders().await;
			ToolContext::new(folders[idx].clone(), CoderMode::Agent)
		}

		#[tokio::test]
		async fn relative_path_inside_active_folder_passes_through() {
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let (folder, out) = tools
				.resolve_workspace_path("src/lib.rs", &cx, "read_file")
				.await
				.expect("relative path should pass through");
			assert_eq!(out, "src/lib.rs");
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn synthetic_active_path_strips_to_relative() {
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let active_name = registry.folders().await[0].folder.name.clone();
			let raw = format!("/workspace/{active_name}/src/foo.rs");
			let (folder, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("synthetic active-folder path should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn synthetic_active_path_with_no_tail_resolves_to_root() {
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let active_name = registry.folders().await[0].folder.name.clone();
			let raw = format!("/workspace/{active_name}");
			let (folder, out) = tools
				.resolve_workspace_path(&raw, &cx, "list_dir")
				.await
				.expect("synthetic root should resolve");
			assert_eq!(out, ".");
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn synthetic_sibling_path_routes_to_other_folder() {
			let one = TempDir::new().unwrap();
			let two = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let two_path = camino::Utf8PathBuf::from_path_buf(two.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path(), two_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let folders = registry.folders().await;
			let other = folders[1].clone();
			let other_name = other.folder.name.clone();
			let raw = format!("/workspace/{other_name}/src/foo.rs");
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("synthetic sibling path should now route to the sibling folder");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &other), "expected route to sibling folder");
		}

		#[tokio::test]
		async fn relative_sibling_basename_routes_to_other_folder() {
			let one = TempDir::new().unwrap();
			let two = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let two_path = camino::Utf8PathBuf::from_path_buf(two.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path(), two_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let folders = registry.folders().await;
			let other = folders[1].clone();
			let other_name = other.folder.name.clone();
			// The lower-friction form: agent writes `<sibling-name>/foo.rs`
			// (without the `/workspace/` prefix). Same routing.
			let raw = format!("{other_name}/foo.rs");
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "list_dir")
				.await
				.expect("relative sibling path should route");
			assert_eq!(out, "foo.rs");
			assert!(Arc::ptr_eq(&target, &other));
		}

		#[tokio::test]
		async fn relative_sibling_basename_alone_routes_to_root() {
			let one = TempDir::new().unwrap();
			let two = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let two_path = camino::Utf8PathBuf::from_path_buf(two.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path(), two_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let folders = registry.folders().await;
			let other = folders[1].clone();
			let other_name = other.folder.name.clone();
			let (target, out) = tools
				.resolve_workspace_path(&other_name, &cx, "list_dir")
				.await
				.expect("bare sibling basename should list its root");
			assert_eq!(out, ".");
			assert!(Arc::ptr_eq(&target, &other));
		}

		/// Parent project + a worktree bound under it at
		/// `.worktrees/moon-agent-1` (plus a second, unbound
		/// worktree dir), mirroring what `create_worktree_session`
		/// registers (ADR 0029). Returns the `TempDir` guard so the
		/// tree outlives the test body.
		async fn build_registry_with_worktree() -> (Arc<WorkspaceRegistry>, ToolRegistry, TempDir) {
			let parent = TempDir::new().unwrap();
			let parent_path = camino::Utf8PathBuf::from_path_buf(parent.path().canonicalize().unwrap()).unwrap();
			let wt_path = parent_path.join(".worktrees/moon-agent-1");
			std::fs::create_dir_all(wt_path.as_std_path()).unwrap();
			std::fs::create_dir_all(parent_path.join(".worktrees/moon-agent-2").as_std_path()).unwrap();
			let registry = Arc::new(WorkspaceRegistry::new("test-workspace".into()));
			registry.add_folder(parent_path.clone()).await.unwrap();
			registry
				.add_worktree_folder(wt_path, parent_path.to_string(), "moon/agent-1".into())
				.await
				.unwrap();
			let workspaces_dir = Utf8PathBuf::from(parent_path.parent().unwrap());
			let web = crate::web::WebClient::new().expect("web client builds in tests");
			let tools = ToolRegistry::new(
				registry.clone(),
				workspaces_dir,
				web,
				Arc::default(),
				crate::models::shared(crate::models::CoderModels::default()),
			);
			(registry, tools, parent)
		}

		#[tokio::test]
		async fn worktree_session_absolute_parent_path_collapses_to_worktree() {
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 1).await;
			let parent = registry.folders().await[0].clone();
			let raw = format!("{}/src/foo.rs", parent.folder.path);
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "write_file")
				.await
				.expect("parent-rooted absolute path should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(
				Arc::ptr_eq(&target, &cx.folder),
				"a worktree session addressing its parent must stay in the worktree"
			);
		}

		#[tokio::test]
		async fn worktree_session_synthetic_parent_path_collapses_to_worktree() {
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 1).await;
			let parent_name = registry.folders().await[0].folder.name.clone();
			let raw = format!("/workspace/{parent_name}/src/foo.rs");
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "write_file")
				.await
				.expect("synthetic parent path should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &cx.folder));
		}

		#[tokio::test]
		async fn worktree_session_parent_basename_collapses_to_worktree() {
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 1).await;
			let parent_name = registry.folders().await[0].folder.name.clone();
			let raw = format!("{parent_name}/src/foo.rs");
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "edit_file")
				.await
				.expect("parent basename path should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &cx.folder));
		}

		#[tokio::test]
		async fn worktree_session_own_tail_under_parent_strips_to_worktree_relative() {
			// The container-mode form the system prompt now
			// advertises: the worktree spelled out as a tail under
			// the parent's mount.
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 1).await;
			let parent_name = registry.folders().await[0].folder.name.clone();
			let raw = format!("/workspace/{parent_name}/.worktrees/moon-agent-1/src/foo.rs");
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("own worktree tail should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &cx.folder));
		}

		#[tokio::test]
		async fn worktree_session_other_worktree_tail_stays_on_parent() {
			// Another agent's checkout under the same parent is
			// *not* collapsed — the path is physically correct as
			// addressed.
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 1).await;
			let parent = registry.folders().await[0].clone();
			let raw = format!("{}/.worktrees/moon-agent-2/src/foo.rs", parent.folder.path);
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("sibling worktree tail should resolve");
			assert_eq!(out, ".worktrees/moon-agent-2/src/foo.rs");
			assert!(Arc::ptr_eq(&target, &parent));
		}

		#[tokio::test]
		async fn parent_session_parent_path_untouched_by_worktree_collapse() {
			let (registry, tools, _guard) = build_registry_with_worktree().await;
			let cx = make_cx(&registry, 0).await;
			let parent = registry.folders().await[0].clone();
			let raw = format!("{}/src/foo.rs", parent.folder.path);
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "write_file")
				.await
				.expect("parent path from a parent session should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &parent));
		}

		#[tokio::test]
		async fn explicit_dot_slash_disambiguates_same_named_subdir() {
			// A directory inside the active folder happens to share a
			// sibling's basename. Prefixing with `./` opts out of the
			// cross-folder routing so the path passes through against
			// the active folder.
			let one = TempDir::new().unwrap();
			let two = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let two_path = camino::Utf8PathBuf::from_path_buf(two.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path(), two_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let other_name = registry.folders().await[1].folder.name.clone();
			let raw = format!("./{other_name}/foo.rs");
			let (folder, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("./<sibling-name> should pass through against the active folder");
			assert_eq!(out, raw);
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn synthetic_unbound_name_errors_with_bound_list() {
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let active_name = registry.folders().await[0].folder.name.clone();
			let raw = "/workspace/no-such-folder/src/foo.rs";
			let err = tools.resolve_workspace_path(raw, &cx, "read_file").await.unwrap_err();
			match err {
				CoderError::ToolFailed { tool, message } => {
					assert_eq!(tool, "read_file");
					assert!(message.contains("no-such-folder"), "message: {message}");
					assert!(
						message.contains(&active_name),
						"message should list bound folders: {message}"
					);
				}
				other => panic!("expected ToolFailed, got {other:?}"),
			}
		}

		#[tokio::test]
		async fn unrelated_absolute_path_resolves_out_of_workspace() {
			// Absolute paths outside every bound folder root now
			// route to the out-of-workspace primitives (container
			// when running, host otherwise) instead of being
			// gated. See ADR 0025.
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let target = tools
				.resolve_target("/etc/passwd", &cx, "read_file")
				.await
				.expect("unrelated absolute paths resolve");
			match target {
				super::super::ResolvedTarget::OutOfWorkspace { abs_path } => {
					assert_eq!(abs_path.as_str(), "/etc/passwd");
				}
				super::super::ResolvedTarget::InWorkspace { .. } => {
					panic!("expected out-of-workspace routing for /etc/passwd")
				}
			}
		}

		#[tokio::test]
		async fn out_of_workspace_read_write_round_trips_on_host() {
			// No container in the test environment, so the
			// out-of-workspace primitives fall back to the host
			// filesystem. A file outside every bound folder should
			// read and write the same bytes back — proving the gate
			// is lifted end-to-end, not just at the resolver.
			let bound = TempDir::new().unwrap();
			let bound_path = camino::Utf8PathBuf::from_path_buf(bound.path().to_path_buf()).unwrap();
			// A *separate* temp dir, deliberately not bound.
			let external = TempDir::new().unwrap();
			let external_path = camino::Utf8PathBuf::from_path_buf(external.path().canonicalize().unwrap()).unwrap();
			let file = external_path.join("outside.txt");

			let (registry, tools) = build_registry(&[bound_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;

			let write_args = serde_json::json!({ "path": file.as_str(), "content": "hello outside\n" });
			tools
				.write_file(&write_args, &cx)
				.await
				.expect("out-of-workspace write should succeed on host");

			let read_args = serde_json::json!({ "path": file.as_str() });
			let read = tools
				.read_file(&read_args, &cx)
				.await
				.expect("out-of-workspace read should succeed on host");
			assert_eq!(read["content"].as_str().unwrap(), "1|hello outside\n");
		}

		#[tokio::test]
		async fn read_file_returns_image_files_as_typed_attachments() {
			// A PNG (NUL bytes in the header → binary by the
			// `looks_binary` heuristic) used to be a hard
			// "binary file" error; now it comes back as a typed
			// image the runner forwards to the model.
			let bound = TempDir::new().unwrap();
			let bound_path = camino::Utf8PathBuf::from_path_buf(bound.path().to_path_buf()).unwrap();
			// Smallest valid PNG header + a NUL, enough to trip
			// the binary sniff.
			let png_bytes: [u8; 12] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D];
			std::fs::write(bound.path().join("shot.png"), png_bytes).unwrap();

			let (registry, tools) = build_registry(&[bound_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let read = tools
				.read_file(&serde_json::json!({ "path": "shot.png" }), &cx)
				.await
				.expect("image read should succeed");
			assert!(read["content"].as_str().unwrap().contains("image/png"));
			let images = read["images"].as_array().expect("images key present");
			assert_eq!(images.len(), 1);
			assert_eq!(images[0]["mime"].as_str().unwrap(), "image/png");
			let data_url = images[0]["data_url"].as_str().unwrap();
			assert!(data_url.starts_with("data:image/png;base64,"), "got: {data_url}");

			// Non-image binaries keep the old hard error.
			std::fs::write(bound.path().join("blob.bin"), [0u8, 1, 2, 3]).unwrap();
			let err = tools
				.read_file(&serde_json::json!({ "path": "blob.bin" }), &cx)
				.await
				.expect_err("non-image binary still errors");
			assert!(err.to_string().contains("binary file"), "got: {err}");
		}

		#[test]
		fn image_mime_for_maps_known_extensions_only() {
			assert_eq!(image_mime_for("a/b/shot.PNG"), Some("image/png"));
			assert_eq!(image_mime_for("x.jpeg"), Some("image/jpeg"));
			assert_eq!(image_mime_for("x.jpg"), Some("image/jpeg"));
			assert_eq!(image_mime_for("x.webp"), Some("image/webp"));
			assert_eq!(image_mime_for("x.gif"), Some("image/gif"));
			assert_eq!(image_mime_for("x.svg"), None);
			assert_eq!(image_mime_for("x.bmp"), None);
			assert_eq!(image_mime_for("noext"), None);
		}

		#[tokio::test]
		async fn out_of_workspace_list_dir_lists_external_children_on_host() {
			let bound = TempDir::new().unwrap();
			let bound_path = camino::Utf8PathBuf::from_path_buf(bound.path().to_path_buf()).unwrap();
			let external = TempDir::new().unwrap();
			let external_path = camino::Utf8PathBuf::from_path_buf(external.path().canonicalize().unwrap()).unwrap();
			std::fs::write(external_path.join("a.txt"), "x").unwrap();
			std::fs::create_dir(external_path.join("sub")).unwrap();

			let (registry, tools) = build_registry(&[bound_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;

			let args = serde_json::json!({ "path": external_path.as_str() });
			let out = tools.list_dir(&args, &cx).await.expect("out-of-workspace list_dir");
			let entries = out["entries"].as_str().unwrap();
			assert!(entries.contains("dir  sub"), "entries: {entries}");
			assert!(entries.contains("file a.txt"), "entries: {entries}");
		}

		#[tokio::test]
		async fn absolute_host_path_inside_active_folder_strips_to_relative() {
			// Host-mode form: the system prompt advertises bound
			// folders by their host abs path; the model joins file
			// paths onto it. The resolver must route to the matching
			// folder and emit the inside-folder relative path.
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let raw = format!("{}/src/foo.rs", one_path.as_str());
			let (folder, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("abs path under active folder should resolve");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn absolute_host_path_at_folder_root_resolves_to_dot() {
			let one = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let (folder, out) = tools
				.resolve_workspace_path(one_path.as_str(), &cx, "list_dir")
				.await
				.expect("abs path equal to folder root should resolve to '.'");
			assert_eq!(out, ".");
			assert!(Arc::ptr_eq(&folder, &cx.folder));
		}

		#[tokio::test]
		async fn absolute_host_path_inside_sibling_routes_cross_folder() {
			let one = TempDir::new().unwrap();
			let two = TempDir::new().unwrap();
			let one_path = camino::Utf8PathBuf::from_path_buf(one.path().to_path_buf()).unwrap();
			let two_path = camino::Utf8PathBuf::from_path_buf(two.path().to_path_buf()).unwrap();
			let (registry, tools) = build_registry(&[one_path.as_path(), two_path.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let folders = registry.folders().await;
			let other = folders[1].clone();
			let raw = format!("{}/src/foo.rs", two_path.as_str());
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("abs path under sibling should route cross-folder");
			assert_eq!(out, "src/foo.rs");
			assert!(Arc::ptr_eq(&target, &other));
		}

		#[tokio::test]
		async fn absolute_host_path_under_nested_bound_folder_picks_inner() {
			// `/foo/bar/sub` should route to `/foo/bar/sub` when both
			// `/foo/bar` and `/foo/bar/sub` are bound — the longer
			// match wins, matching how the file tree groups files.
			let outer = TempDir::new().unwrap();
			let outer_path = camino::Utf8PathBuf::from_path_buf(outer.path().to_path_buf()).unwrap();
			let inner_dir = outer_path.join("nested");
			std::fs::create_dir(inner_dir.as_std_path()).unwrap();
			let (registry, tools) = build_registry(&[outer_path.as_path(), inner_dir.as_path()]).await;
			let cx = make_cx(&registry, 0).await;
			let inner_entry = registry.folders().await[1].clone();
			let raw = format!("{}/file.rs", inner_dir.as_str());
			let (target, out) = tools
				.resolve_workspace_path(&raw, &cx, "read_file")
				.await
				.expect("nested bound folder path should resolve to the inner folder");
			assert_eq!(out, "file.rs");
			assert!(Arc::ptr_eq(&target, &inner_entry));
		}
	}

	mod coordinator_mode {
		use crate::CoderMode;

		#[test]
		fn coordinator_is_read_only() {
			assert!(!CoderMode::Coordinator.allows_writes());
			assert!(CoderMode::Agent.allows_writes());
			assert!(!CoderMode::Research.allows_writes());
		}

		#[test]
		fn coordinator_does_not_advertise_task_or_ask_user() {
			// The depth-1 cap + the coordinator's "delegate, don't
			// do" posture both hinge on these.
			assert!(!CoderMode::Coordinator.allows_task_tool());
			assert!(!CoderMode::Coordinator.allows_ask_user());
			assert!(CoderMode::Agent.allows_task_tool());
			assert!(CoderMode::Agent.allows_ask_user());
		}

		#[test]
		fn coordinator_wire_string_round_trips() {
			assert_eq!(CoderMode::Coordinator.as_wire(), "coordinator");
			assert_eq!(CoderMode::from_wire("coordinator").unwrap(), CoderMode::Coordinator);
			assert_eq!(CoderMode::from_wire("agent").unwrap(), CoderMode::Agent);
			assert_eq!(CoderMode::from_wire("research").unwrap(), CoderMode::Research);
			assert!(CoderMode::from_wire("bogus").is_err());
		}

		#[test]
		fn from_top_level_wire_defaults_to_agent() {
			// `None` / absent = the pre-coordinator default, byte-
			// compatible with every existing session on disk.
			assert_eq!(CoderMode::from_top_level_wire(None), CoderMode::Agent);
			assert_eq!(CoderMode::from_top_level_wire(Some("agent")), CoderMode::Agent);
			assert_eq!(
				CoderMode::from_top_level_wire(Some("coordinator")),
				CoderMode::Coordinator
			);
			// `research` is sub-agent-only; a top-level session
			// carrying it falls back to `Agent`.
			assert_eq!(CoderMode::from_top_level_wire(Some("research")), CoderMode::Agent);
			// Unknown string: falls back to `Agent`, doesn't panic.
			assert_eq!(CoderMode::from_top_level_wire(Some("bogus")), CoderMode::Agent);
		}
	}
}
