diff --git a/AGENTS.md b/AGENTS.md index a567ce8..83fb6ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,12 @@ remove it — with the volume's free space live on screen the whole time. Two phases: explore (treemap, breadcrumbs, the selection and status lines) and review (the marked list, the removal mode, the confirmation). Marking is never destructive. +`disktree mcp` is the same thing for an AI agent: an MCP server over stdio +that scans with `disktree-core`, marks, and opens the review screen in a +window over a per-user socket (`disktree-link`). The agent marks; the +person removes. There is no tool that deletes, and the link has no request +that deletes. + ## Commands ```sh @@ -106,6 +112,19 @@ and `cargo build --release` directly; CI runs the gate on both systems. the tree, which `-X` and the media exception allow, is reported and never added to this volume's free space; see `space::attribution`. The final number comes from `statvfs` before and after. +10. **An agent marks; a person removes.** `disktree mcp` has no tool that + deletes, and the window link has no request that does. `review` only + opens the review screen. When a window is linked it is the agent's + source of truth: `ls`, `find` and `scan` are answered from its tree, + a mark is measured from it, and its marks are the marks — every link + answer carries them, and the MCP session replaces its own with what + the window says. The session's own scans are read only while no + window is open. The listing and search logic is `core::query`, shared + by both sides, so they answer alike. +11. **The link is a same-user socket, nothing more.** `disktree-link` + binds `0600` in a per-user directory and has no token, because the + agent already runs as the user and `disktree mcp` scans on its own. + Do not add a port. ## Where changes belong @@ -124,6 +143,10 @@ and `cargo build --release` directly; CI runs the gate on both systems. | the mosaic's painting or labels | `crates/disktree-app/src/treemap_view.rs` | | layout of a screen | `crates/disktree-app/src/views.rs` | | colours derived from the theme | `crates/disktree-app/src/palette.rs` | +| what an agent can ask of the window, and the answer | `crates/disktree-link/src/lib.rs` (messages), `crates/disktree-app/src/agent.rs` (handling) | +| a tool, its parameters or its wording | `crates/disktree-mcp/src/server.rs` (the MCP surface), `crates/disktree-mcp/src/session.rs` (what it does) | +| how a listing or a plan reads to an agent | `crates/disktree-mcp/src/report.rs` | +| the procedure an agent follows | `skills/disktree-free-space/SKILL.md` | ## Verification expectations @@ -132,6 +155,10 @@ and `cargo build --release` directly; CI runs the gate on both systems. against real temporary trees. * The screens are covered by window-harness tests that draw frames and press keys, including one that marks a directory, confirms the removal and checks - the files are gone while unmarked neighbours are untouched. + the files are gone while unmarked neighbours are untouched, and one that + drives the window over the real socket as `disktree mcp` does. +* `disktree-mcp` tests every tool as a function against a temporary tree, + with a stand-in window on the socket for the linked cases; + `disktree-link` tests the socket itself. * Rendering was verified by those tests and by running the app against a real home directory; it has not been eyeballed in every theme and font size. diff --git a/Cargo.lock b/Cargo.lock index 71482b5..67e2268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,7 +483,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -1319,6 +1319,40 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1424,6 +1458,8 @@ version = "0.11.0" dependencies = [ "anyhow", "disktree-core", + "disktree-link", + "disktree-mcp", "dunce", "gpui-kit", "gpui-omarchy", @@ -1447,6 +1483,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "disktree-link" +version = "0.11.0" +dependencies = [ + "disktree-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "disktree-mcp" +version = "0.11.0" +dependencies = [ + "anyhow", + "disktree-core", + "disktree-link", + "rmcp", + "schemars", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3262,6 +3323,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -4802,6 +4869,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -5685,6 +5758,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "chrono", + "futures", + "indexmap", + "pastey 0.2.3", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.5", +] + [[package]] name = "ropey" version = "2.0.0-beta.1" @@ -5956,6 +6064,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", @@ -6445,6 +6554,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -6886,9 +7001,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio-rustls" version = "0.26.5" diff --git a/Cargo.toml b/Cargo.toml index 30dfed0..8643259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "3" -members = ["crates/disktree-app", "crates/disktree-core", "xtask"] +members = [ + "crates/disktree-app", + "crates/disktree-core", + "crates/disktree-link", + "crates/disktree-mcp", + "xtask", +] [workspace.package] version = "0.11.0" @@ -11,6 +17,8 @@ repository = "https://github.com/tobi/disktree" [workspace.dependencies] disktree-core = { path = "crates/disktree-core" } +disktree-link = { path = "crates/disktree-link" } +disktree-mcp = { path = "crates/disktree-mcp" } anyhow = "1" rayon = "1" rustc-hash = "2" diff --git a/README.md b/README.md index 8044269..31c1470 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ disktree # scan the home directory disktree --disk # the whole disk it lives on disktree ~/src # or any directory disktree --help # options: apparent size, follow links, skip hidden, … +disktree mcp # serve an AI agent over stdio; see "With an AI agent" ``` ### The screen @@ -246,6 +247,53 @@ first (git work that exists nowhere else, a tool's own clean command) and touching nothing else. A name holding a newline is left out of the list and escaped in the prompt, so it cannot pass for another path. +## With an AI agent + +disktree is also an [MCP](https://modelcontextprotocol.io) server, so an +agent such as Claude Code or Codex can do the looking while you keep the +deciding. One command connects it, and there is nothing to configure, sign +in to or approve: the agent runs `disktree mcp` as you, over stdio, and can +do no more than your shell can. + +```sh +claude mcp add --scope user disktree -- disktree mcp +``` + +(Codex: `codex mcp add disktree -- disktree mcp`. Any other client: a +server named `disktree` with the command `disktree` and the argument +`mcp`. The window's **Work with an AI agent** line, under the disk, and +**Help › Work with an AI Agent…** on macOS, show each command with the +binary's full path and a Copy button.) + +Then ask it what is eating your disk. The agent walks the tree largest +first, lists every cache, build directory and package store at once, and +marks what can go. Marking is all it can do: `disktree_review` opens +disktree's review screen, and you press Enter there. There is no tool that +deletes. + +When a disktree window is open, its tree is what the agent reads. `ls`, +`find` and `scan` are answered from the mosaic on screen, a mark is +measured from it, and the two of you see the same numbers. The agent's +marks appear in the mosaic and in the free-space meter as it makes them, +`show` moves the view to what it is looking at, a scan it asks for runs in +the window, and the header names the agent and its last action while it is +connected. With no window open the agent scans on its own, in seconds for a +home directory, and can open a window for the review. Without a display it +works headless and says so. + +The tools: `disktree_status`, `disktree_scan`, `disktree_ls`, +`disktree_find`, `disktree_show`, `disktree_mark`, `disktree_unmark` and +`disktree_review`. Each `mark` answers with the real plan: what the guards +below keep back and why, what another mark already covers, and the free +space it gives back on the volume, counted the way the meter counts it. +The prompt `free-space` (in Claude Code, `/mcp__disktree__free-space`) and +the resource `disktree://skills/free-space` carry the procedure the agent +should follow, from `skills/disktree-free-space/SKILL.md`. + +The window listens on a socket only you can reach: `$XDG_RUNTIME_DIR` or +`$TMPDIR`, or `DISKTREE_LINK` to choose. On Windows the agent works +headless for now; the window link needs a named pipe there. + ## What it measures - **Disk usage** by default: `st_blocks × 512`, the number `du` reports and the @@ -401,6 +449,9 @@ gone while their neighbours are not. | path | what lives there | | --- | --- | | `crates/disktree-core` | scanning, the tree, the squarified layout, free space and removal — no UI | +| `crates/disktree-mcp` | `disktree mcp`: the agent's tools over stdio, on `disktree-core` | +| `crates/disktree-link` | the socket between a window and an agent, and its messages | +| `skills/` | the free-space procedure the MCP server hands an agent | | `crates/disktree-app/src/state.rs` | every action the interface can take, and the key map | | `crates/disktree-app/src/views.rs` | the screens | | `crates/disktree-app/src/treemap_view.rs` | painting the mosaic and its labels | diff --git a/crates/disktree-app/Cargo.toml b/crates/disktree-app/Cargo.toml index 4bc758a..f86ae8d 100644 --- a/crates/disktree-app/Cargo.toml +++ b/crates/disktree-app/Cargo.toml @@ -17,6 +17,8 @@ workspace = true gpui-kit = { version = "=0.6.6", default-features = false, features = ["assets"] } gpui-omarchy = "0.1.3" disktree-core = { workspace = true } +disktree-link = { workspace = true } +disktree-mcp = { workspace = true } anyhow = { workspace = true } dunce = "1.0.5" rustc-hash = { workspace = true } diff --git a/crates/disktree-app/src/agent.rs b/crates/disktree-app/src/agent.rs new file mode 100644 index 0000000..47b8feb --- /dev/null +++ b/crates/disktree-app/src/agent.rs @@ -0,0 +1,599 @@ +//! The window's end of the agent link. +//! +//! `disktree mcp` scans and marks on its own; this is how an open window +//! follows along. A listener on the per-user socket queues what an agent +//! asks, the UI thread answers between frames, and every answer carries the +//! window's state, so the agent's view of the marks is always the window's. +//! +//! While a window is open it is the agent's source of truth: `ls`, `find` +//! and `scan` are answered from the tree on screen, and a mark is measured +//! from it too, so the agent and the person read the same numbers. +//! +//! An agent can look, mark and open the review screen. It cannot remove +//! anything: the review screen is where the person decides, same as when +//! they marked everything themselves. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use disktree_core::query; +use disktree_core::removal::{RemovalMode, Target}; +use disktree_core::space::space_info; +use disktree_core::tree::{Node, path_of}; +use disktree_link::{ + FindRequest, FoundList, Incoming, Insight, Listener, Listing, Mark, + MarkRequest, Refused, Removal, Request, Response, ScanOptionsRequest, + Space, State, TreeSummary, VERSION, +}; +use gpui_kit::Context; +use gpui_omarchy::Status; + +use crate::marks::{display_path, is_hidden}; +use crate::state::{Disktree, Screen, now_seconds}; + +/// How often the UI thread looks for agent requests. Short enough that a +/// mark lands in the mosaic before the agent's next sentence. +const POLL: Duration = Duration::from_millis(50); + +/// Who is connected and what they last did, for the header and the panel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Presence { + pub client: String, + pub action: String, + pub at: Instant, +} + +/// The listener and what has come over it. +#[derive(Debug)] +pub struct AgentLink { + listener: Listener, + pub presence: Option, + /// A path to show once the scan an agent asked for lands. + pending_show: Option, + /// The last removal, for an agent that asks what was freed. + pub last_removal: Option, +} + +impl AgentLink { + pub const fn new(listener: Listener) -> Self { + Self { + listener, + presence: None, + pending_show: None, + last_removal: None, + } + } +} + +/// The screen as the protocol names it. +const fn screen_name(screen: Screen) -> &'static str { + match screen { + Screen::Explore => "explore", + Screen::Review => "review", + Screen::Running => "running", + Screen::Done => "done", + } +} + +/// The commands that connect each client, with the binary where it is. +/// The executable's own path, so the command works from a bundle, a +/// symlink on `PATH`, or a build directory alike. +pub fn connect_commands(exe: &Path) -> Vec<(&'static str, String)> { + let exe = exe.display(); + vec![ + ( + "Claude Code", + format!("claude mcp add --scope user disktree -- \"{exe}\" mcp"), + ), + ("Codex", format!("codex mcp add disktree -- \"{exe}\" mcp")), + ( + "Other MCP clients", + format!( + "{{\"mcpServers\": {{\"disktree\": {{\"command\": \"{exe}\", \ + \"args\": [\"mcp\"]}}}}}}" + ), + ), + ] +} + +/// What to ask an agent once it is connected. +pub const FIRST_PROMPT: &str = + "What is eating my disk? Use disktree to find out and mark what can go."; + +impl Disktree { + /// Start answering agents on `listener`. + pub fn attach_agent(&mut self, listener: Listener, cx: &Context<'_, Self>) { + self.agent = Some(AgentLink::new(listener)); + Self::poll_agent(cx); + } + + fn poll_agent(cx: &Context<'_, Self>) { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(POLL).await; + let keep_going = + this.update(cx, Self::poll_agent_once).unwrap_or(false); + if !keep_going { + break; + } + } + }) + .detach(); + } + + /// Answer everything that arrived. Returns whether there is a listener + /// to keep polling. Driven by the timer above, and by the tests. + pub(crate) fn poll_agent_once( + &mut self, + cx: &mut Context<'_, Self>, + ) -> bool { + let Some(agent) = &self.agent else { + return false; + }; + let incoming = agent.listener.drain(); + if incoming.is_empty() { + self.forget_departed_agent(cx); + return true; + } + for item in incoming { + match item { + Incoming::Request { request, reply, .. } => { + let response = self.answer_agent(&request, cx); + reply.send(response); + } + Incoming::Closed { .. } => {} + } + } + self.forget_departed_agent(cx); + cx.notify(); + true + } + + /// Once the last connection is gone, so is the presence. + fn forget_departed_agent(&mut self, cx: &mut Context<'_, Self>) { + if let Some(agent) = &mut self.agent + && agent.presence.is_some() + && agent.listener.connections() == 0 + { + agent.presence = None; + cx.notify(); + } + } + + fn note_agent(&mut self, client: Option<&str>, action: String) { + let Some(agent) = &mut self.agent else { + return; + }; + let client = client + .map(str::to_owned) + .or_else(|| agent.presence.as_ref().map(|p| p.client.clone())) + .unwrap_or_else(|| "an agent".to_string()); + agent.presence = Some(Presence { + client, + action, + at: Instant::now(), + }); + } + + fn answer_agent( + &mut self, + request: &Request, + cx: &mut Context<'_, Self>, + ) -> Response { + match request { + Request::Hello { client, version } => { + if *version != VERSION { + return Response::error(format!( + "this window speaks link version {VERSION}, the \ + agent version {version}; restart the older one" + )); + } + self.note_agent(Some(client), "connected".to_string()); + Response::State(self.agent_state()) + } + Request::State => Response::State(self.agent_state()), + Request::Show { root, path } => self.agent_show(root, path, cx), + Request::Scan { root, options } => { + self.agent_scan(root, options, cx) + } + Request::Ls { path, limit } => self.agent_ls(path, *limit), + Request::Find(request) => self.agent_find(request), + Request::Mark { root, targets } => { + self.agent_marks(root, targets, cx) + } + Request::Unmark { paths } => { + if self.screen == Screen::Running { + return Response::error("a removal is running"); + } + if paths.is_empty() { + self.clear_marks(cx); + } else { + for path in paths { + self.unmark(path, cx); + } + } + self.leave_review_if_empty(); + self.note_agent(None, "unmarked".to_string()); + Response::State(self.agent_state()) + } + Request::Review => { + if self.screen == Screen::Running { + return Response::error("a removal is running"); + } + if self.marks.is_empty() { + return Response::error("nothing is marked"); + } + self.screen = Screen::Review; + self.confirm_open = false; + self.note_agent(None, "opened the review screen".to_string()); + cx.notify(); + Response::Ok + } + } + } + + /// Scan `root` with the options the agent gave, or rescan it. Answered + /// at once; the agent polls the state until `scanning` turns false. + fn agent_scan( + &mut self, + root: &Path, + options: &ScanOptionsRequest, + cx: &mut Context<'_, Self>, + ) -> Response { + if self.screen == Screen::Running { + return Response::error("a removal is running"); + } + if let Some(apparent) = options.apparent_size { + self.options.apparent_size = apparent; + } + if let Some(hidden) = options.include_hidden { + self.options.include_hidden = hidden; + } + if let Some(one) = options.one_filesystem { + self.options.one_filesystem = one; + } + if let Some(depth) = options.max_depth { + self.options.max_depth = depth; + } + self.note_agent( + None, + format!("scanning {}", display_path(root, self.home.as_deref())), + ); + if root == self.root_path { + self.screen = Screen::Explore; + self.start_scan(cx); + } else { + self.set_root(root.to_path_buf(), cx); + } + cx.notify(); + Response::State(self.agent_state()) + } + + /// The node at `path` in the tree on screen, or why there is none. + fn agent_node(&self, path: &Path) -> Result<&Node, String> { + let tree = self.tree.as_deref().ok_or_else(|| { + if self.scan.is_some() { + "the window is still scanning; ask again when the state \ + says scanning is false" + } else { + "the window has no tree" + } + .to_string() + })?; + let relative = path.strip_prefix(&self.root_path).map_err(|_| { + format!( + "{} is outside the window's tree, rooted at {}", + display_path(path, self.home.as_deref()), + display_path(&self.root_path, self.home.as_deref()) + ) + })?; + tree.descend(relative).map(|(_, node)| node).ok_or_else(|| { + format!( + "{} is not in the window's tree: it may be new, hidden, \ + below the depth limit, or on another filesystem", + display_path(path, self.home.as_deref()) + ) + }) + } + + /// What is in a directory of the tree on screen. + fn agent_ls(&mut self, path: &Path, limit: usize) -> Response { + let listing = match self.agent_node(path) { + Ok(node) => Listing::of( + &self.root_path, + self.tree.as_ref().map_or(0, |tree| tree.bytes), + path, + node, + limit, + ), + Err(message) => return Response::error(message), + }; + self.note_agent( + None, + format!("reading {}", display_path(path, self.home.as_deref())), + ); + Response::Listing(listing) + } + + /// The largest entries of the tree on screen matching a description. + fn agent_find(&mut self, request: &FindRequest) -> Response { + let filter = match request.filter() { + Ok(filter) => filter, + Err(message) => return Response::error(message), + }; + let from = request + .path + .clone() + .unwrap_or_else(|| self.root_path.clone()); + let found = match self.agent_node(&from) { + Ok(node) => { + FoundList::of(&query::find(&from, node, &filter, request.limit)) + } + Err(message) => return Response::error(message), + }; + self.note_agent(None, "searching the tree".to_string()); + Response::Found(found) + } + + /// Mark what the agent asks, measured from the tree on screen. A path + /// the tree does not have is refused, not guessed at. + fn agent_marks( + &mut self, + root: &Path, + targets: &[MarkRequest], + cx: &mut Context<'_, Self>, + ) -> Response { + if self.screen == Screen::Running { + return Response::error("a removal is running"); + } + if root != self.root_path { + self.set_root(root.to_path_buf(), cx); + } + let mut marked = 0_usize; + let mut refused = Vec::new(); + for target in targets { + // Without a tree yet, the agent's own measurement stands in; + // `Marks::refresh` corrects it when the scan lands. + let measured = if self.tree.is_some() { + match self.agent_node(&target.path) { + Ok(node) => (node.bytes, node.is_dir()), + Err(reason) => { + refused.push(Refused { + path: target.path.clone(), + reason, + }); + continue; + } + } + } else { + (target.bytes, target.is_dir) + }; + if self.agent_mark(Target { + hidden: is_hidden(&target.path), + path: target.path.clone(), + bytes: measured.0, + is_dir: measured.1, + }) { + marked += 1; + } else { + refused.push(Refused { + path: target.path.clone(), + reason: "the scanned root itself, or outside it".into(), + }); + } + } + self.leave_review_if_empty(); + self.note_agent( + None, + format!( + "marked {marked} path{}", + if marked == 1 { "" } else { "s" } + ), + ); + cx.notify(); + Response::Marked { + state: self.agent_state(), + refused, + } + } + + /// A review screen over nothing is an empty list; go back to the tree. + fn leave_review_if_empty(&mut self) { + if self.screen == Screen::Review && self.marks.is_empty() { + self.screen = Screen::Explore; + } + } + + /// Mark `target` for an agent: like a press of Space, except it never + /// unmarks, and a path inside a marked directory is left alone rather + /// than toggled. Returns whether the path is marked afterwards. + fn agent_mark(&mut self, target: Target) -> bool { + if self.marks.contains(&target.path) { + return true; + } + if !target.path.starts_with(&self.root_path) + || target.path == self.root_path + { + return false; + } + if self.marked_ancestor(&target.path).is_some() { + return true; + } + self.space_baseline = self.space_baseline.or(self.space); + let path = target.path.clone(); + self.marks.toggle(target); + // A directory's mark absorbs the marks inside it, as Space does. + let inside: Vec = self + .marks + .items() + .iter() + .filter(|item| item.path != path && item.path.starts_with(&path)) + .map(|item| item.path.clone()) + .collect(); + for inner in &inside { + self.marks.remove(inner); + } + true + } + + /// Show `path`, rescanning from `root` first when the window is + /// elsewhere. A directory is opened; a file is selected in its own. + fn agent_show( + &mut self, + root: &Path, + path: &Path, + cx: &mut Context<'_, Self>, + ) -> Response { + if self.screen == Screen::Running { + return Response::error("a removal is running"); + } + self.note_agent( + None, + format!("looking at {}", display_path(path, self.home.as_deref())), + ); + if root != self.root_path { + self.set_root(root.to_path_buf(), cx); + } + if self.scan.is_some() || self.tree.is_none() { + // The tree is on its way; show the path when it lands. + if let Some(agent) = &mut self.agent { + agent.pending_show = Some(path.to_path_buf()); + } + return Response::Ok; + } + if self.show_path(path, cx) { + Response::Ok + } else { + Response::error(format!( + "{} is not in the window's tree", + display_path(path, self.home.as_deref()) + )) + } + } + + /// Put `path` on screen. Returns whether it was found. + fn show_path(&mut self, path: &Path, cx: &mut Context<'_, Self>) -> bool { + let Some(crumbs) = self.crumbs_for_path(path) else { + return false; + }; + if matches!(self.screen, Screen::Review | Screen::Done) { + self.screen = Screen::Explore; + } + let is_dir = self.node_at(&crumbs).is_some_and(Node::is_dir); + if is_dir { + self.go_to(crumbs, cx); + } else { + self.reveal(crumbs, cx); + } + true + } + + /// Called when a scan lands: show what an agent asked for meanwhile. + pub(crate) fn finish_pending_show(&mut self, cx: &mut Context<'_, Self>) { + let pending = self + .agent + .as_mut() + .and_then(|agent| agent.pending_show.take()); + if let Some(path) = pending { + self.show_path(&path, cx); + } + } + + /// Called when a removal finishes: keep what it did for the agent. + pub(crate) fn record_removal(&mut self) { + let Some(agent) = &mut self.agent else { + return; + }; + let freed = space_info(&self.root_path) + .ok() + .zip(self.space_baseline) + .map(|(now, before)| { + now.available.saturating_sub(before.available) + }); + agent.last_removal = Some(Removal { + mode: match self.removal_mode { + RemovalMode::Trash => "trash".to_string(), + RemovalMode::Permanent => "permanent".to_string(), + }, + removed: usize::try_from(self.run_summary.removed) + .unwrap_or(usize::MAX), + failed: self.run_summary.failed, + bytes: self.run_summary.bytes, + freed, + at: now_seconds(), + }); + } + + /// What the window shows, for the agent. + pub fn agent_state(&self) -> State { + State { + version: VERSION, + root: self.root_path.clone(), + current: self.current_path(), + screen: screen_name(self.screen).to_string(), + scanning: self.scan.is_some(), + scanned: self.tree.is_some(), + marks: self + .marks + .items() + .iter() + .map(|target| Mark { + path: target.path.clone(), + bytes: target.bytes, + is_dir: target.is_dir, + }) + .collect(), + space: self.space.map(|space| Space { + total: space.total, + free: space.free, + available: space.available, + }), + removal: self + .agent + .as_ref() + .and_then(|agent| agent.last_removal.clone()), + tree: self.tree.as_deref().map(|tree| TreeSummary { + bytes: tree.bytes, + files: tree.files, + dirs: u64::from(tree.dirs), + scanned_at: self.scanned_at, + elapsed_ms: self.scan_elapsed.map_or(0, |elapsed| { + u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX) + }), + unreadable: self.progress.errors, + apparent_size: self.options.apparent_size, + include_hidden: self.options.include_hidden, + }), + insights: self + .tree + .as_deref() + .map(|tree| { + self.insights + .iter() + .map(|candidate| Insight { + path: path_of( + &self.root_path, + tree, + &candidate.crumbs, + ), + bytes: candidate.bytes, + what: candidate.finding.describe(), + }) + .collect() + }) + .unwrap_or_default(), + } + } + + /// Copy a connect command, and say so. + pub fn copy_connect_text( + &mut self, + text: String, + what: &str, + cx: &mut Context<'_, Self>, + ) { + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text)); + self.notice = Some((format!("copied {what}"), Status::Success)); + cx.notify(); + } +} diff --git a/crates/disktree-app/src/app_menu.rs b/crates/disktree-app/src/app_menu.rs index faee476..67239a6 100644 --- a/crates/disktree-app/src/app_menu.rs +++ b/crates/disktree-app/src/app_menu.rs @@ -39,6 +39,8 @@ mod menu_actions { GoBack, /// Forward again, after going back. GoForward, + /// Show how to connect an AI agent over MCP. + ConnectAgent, ] ); } @@ -112,5 +114,11 @@ pub fn install(cx: &mut App) { MenuItem::action("Back", GoBack), MenuItem::action("Forward", GoForward), ]), + // Help is where a person looks for "how do I hook this up"; the + // same overlay opens from the line under the disk meter. + Menu::new("Help").items([MenuItem::action( + "Work with an AI Agent\u{2026}", + ConnectAgent, + )]), ]); } diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 92fa2f7..2417fd4 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -11,6 +11,7 @@ // reach one. Ignored elsewhere. #![windows_subsystem = "windows"] +mod agent; mod app_menu; mod appearance; mod git; @@ -51,9 +52,12 @@ const USAGE: &str = "\ disktree — a treemap of what is using your disk usage: disktree [OPTIONS] [PATH] + disktree mcp arguments: PATH directory to scan (default: the home directory) + mcp serve an AI agent over stdio instead of opening a + window: `claude mcp add disktree -- disktree mcp` The window opens on a treemap of the root, largest first. Space marks the selected tile, Enter opens it, c reviews the marked list, ? lists every key. @@ -91,6 +95,12 @@ fn main() -> Result<()> { } fn run() -> Result<()> { + // `disktree mcp` is the agent's disktree: it scans and marks over stdin + // and stdout and opens no window of its own, though it may open one for + // the review screen. + if std::env::args_os().nth(1).is_some_and(|arg| arg == "mcp") { + return disktree_mcp::serve(); + } let saved = power::settings_path() .map_or_else( || Ok(power::PowerEfficiency::default()), @@ -198,6 +208,23 @@ fn run() -> Result<()> { let focus = this.focus.clone(); window.focus(&focus, cx); }); + + // Listen for an agent. Another window already linked, or a + // system without the socket, leaves this one on its own; the + // agent then works headless and says so. + match disktree_link::Listener::bind() { + Ok(listener) => { + let _ = window.update(cx, |this, _, cx| { + this.attach_agent(listener, cx); + }); + } + Err(error) + if error.kind() != std::io::ErrorKind::Unsupported => + { + eprintln!("agent link not available: {error}"); + } + Err(_) => {} + } cx.activate(true); }); Ok(()) diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index ee351f3..783a87b 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -409,6 +409,8 @@ pub struct Disktree { /// Enter was pressed before the search finished: apply on arrival. apply_pending: bool, pub show_help: bool, + /// The "work with an AI agent" overlay is open. + pub show_agent: bool, pub show_selection: bool, pub focus: FocusHandle, /// A trail crumb's sibling menu, when open. @@ -455,6 +457,8 @@ pub struct Disktree { pub scan_elapsed: Option, /// Unix seconds when the tree landed: the "now" ages are measured from. pub scanned_at: i64, + /// The agent link, once a listener is attached; see [`crate::agent`]. + pub agent: Option, } impl Disktree { @@ -528,6 +532,7 @@ impl Disktree { finding: false, apply_pending: false, show_help: false, + show_agent: false, show_selection: true, focus: cx.focus_handle(), crumb_menu: None, @@ -551,6 +556,7 @@ impl Disktree { scan_root: PathBuf::new(), scan_elapsed: None, scanned_at: now_seconds(), + agent: None, }; tree.device = device_for(&tree.root_path); tree.full_disk_access = tree @@ -1017,6 +1023,7 @@ impl Disktree { self.keep_selection_valid(); self.prune_history(); self.select_largest(cx); + self.finish_pending_show(cx); } Err(error) => self.scan_error = Some(error.to_string()), } @@ -2274,6 +2281,7 @@ impl Disktree { } if finished { self.run = None; + self.record_removal(); self.marks.clear(); self.screen = Screen::Done; // The tree on screen is now wrong; start over rather than leaving @@ -2576,6 +2584,13 @@ impl Disktree { } return; } + if self.show_agent { + if matches!(key, "escape" | "q") { + self.show_agent = false; + cx.notify(); + } + return; + } // The search field takes the keyboard while it is open. It is a field // of three keys on purpose: no editor state to keep in sync with the diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 3d7f151..6aff9cc 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -1844,3 +1844,295 @@ fn the_power_menu_offers_only_what_the_cpus_can_tell_apart( draw(cx); assert!(read(&view, cx, |app| app.power_menu.is_none())); } + +/// Drive the window as `disktree mcp` does, over the real socket: say +/// hello, mark, show, open the review, unmark, hang up. The marks land in +/// the window, the screen follows, and the header names the agent while it +/// is there. +#[cfg(unix)] +#[gpui_kit::test] +fn an_agent_marks_shows_and_reviews_over_the_link(cx: &mut TestAppContext) { + use disktree_link::{Link, MarkRequest, Request, Response}; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let root = temp.path().to_path_buf(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + + let socket = temp.path().join("link.sock"); + let listener = + disktree_link::Listener::bind_at(&socket).expect("bind the link"); + update(&view, cx, |app, cx| app.attach_agent(listener, cx)); + + // The client blocks on each answer, so it runs on its own thread while + // the test pumps the window's side, as the timer task would. + let mut link = Some(Link::connect_at(&socket).expect("connect")); + let mut ask = |cx: &mut Window, request: Request| -> Response { + let mut taken = link.take().expect("the link is at hand"); + let worker = std::thread::spawn(move || { + let response = taken.call(&request).expect("an answer"); + (taken, response) + }); + for _ in 0..400 { + update(&view, cx, |app, cx| { + app.poll_agent_once(cx); + }); + if worker.is_finished() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let (returned, response) = worker.join().expect("client thread"); + link = Some(returned); + draw(cx); + response + }; + + let hello = ask( + cx, + Request::Hello { + client: "Claude Code".into(), + version: disktree_link::VERSION, + }, + ); + let Response::State(state) = hello else { + panic!("hello answers with the state: {hello:?}"); + }; + assert_eq!(state.root, root); + assert!(state.scanned && !state.scanning); + assert!(state.marks.is_empty()); + assert_eq!( + read(&view, cx, |app| app + .agent + .as_ref() + .and_then(|agent| agent.presence.clone()) + .map(|presence| presence.client)), + Some("Claude Code".to_string()) + ); + assert!( + cx.debug_bounds("agent-pill").is_some(), + "the header names the connected agent" + ); + + // Marking lands in the window's marks; the agent's own number is only + // a stand-in, and a wrong one is corrected from the tree. + let marked = ask( + cx, + Request::Mark { + root: root.clone(), + targets: vec![MarkRequest { + path: root.join("junk"), + bytes: 7, + is_dir: false, + }], + }, + ); + let Response::Marked { state, refused } = marked else { + panic!("mark answers with the state: {marked:?}"); + }; + assert!(refused.is_empty(), "{refused:?}"); + assert_eq!(state.marks.len(), 1); + // The window measured the mark from its own tree, not from the + // agent's number. + let measured = read(&view, cx, |app| { + app.crumbs_for_path(&root.join("junk")) + .and_then(|crumbs| app.node_at(&crumbs).map(|node| node.bytes)) + }) + .expect("junk is in the tree"); + assert_eq!(state.marks[0].bytes, measured); + assert!(state.marks[0].is_dir); + assert_ne!(measured, 7); + assert_eq!(read(&view, cx, |app| app.plan().bytes()), measured); + assert!(state.tree.is_some_and(|tree| tree.files > 0)); + + // A path the tree does not have is refused, not guessed at. + let missing = ask( + cx, + Request::Mark { + root: root.clone(), + targets: vec![MarkRequest { + path: root.join("nowhere"), + bytes: 1, + is_dir: false, + }], + }, + ); + let Response::Marked { refused, .. } = missing else { + panic!("{missing:?}"); + }; + assert_eq!(refused.len(), 1); + assert_eq!(read(&view, cx, |app| app.marks.len()), 1); + + // The agent reads the window's tree: a listing and a search. + let listed = ask( + cx, + Request::Ls { + path: root.clone(), + limit: 10, + }, + ); + let Response::Listing(listing) = listed else { + panic!("{listed:?}"); + }; + assert!(listing.children.iter().any(|child| child.name == "junk")); + assert_eq!(listing.root_bytes, listing.entry.bytes); + let outside = ask( + cx, + Request::Ls { + path: PathBuf::from("/definitely/elsewhere"), + limit: 10, + }, + ); + assert!( + matches!(outside, Response::Error { ref message } if message.contains("outside")), + "{outside:?}" + ); + let found = ask( + cx, + Request::Find(disktree_link::FindRequest { + kind: "file".into(), + name: Some("blob".into()), + limit: 5, + ..disktree_link::FindRequest::default() + }), + ); + let Response::Found(found) = found else { + panic!("{found:?}"); + }; + // Two blobs, the larger first. + assert_eq!(found.count, 2); + assert_eq!(found.matches[0].path, root.join(".cache/blob.bin")); + assert_eq!(found.matches[1].path, root.join("junk/blob.bin")); + + // A scan the agent asks for runs in the window; the state says when it + // has landed, and the marks survive it. + let rescanned = ask( + cx, + Request::Scan { + root: root.clone(), + options: disktree_link::ScanOptionsRequest::default(), + }, + ); + assert!( + matches!(rescanned, Response::State(ref s) if s.scanning), + "{rescanned:?}" + ); + finish_scan(&view, cx); + let after = ask(cx, Request::State); + let Response::State(after) = after else { + panic!("{after:?}"); + }; + assert!(!after.scanning && after.scanned); + assert_eq!(after.marks.len(), 1); + + // A path inside the mark is left alone, not toggled off or added. + ask( + cx, + Request::Mark { + root: root.clone(), + targets: vec![MarkRequest { + path: root.join("junk/deeper"), + bytes: 100_000, + is_dir: true, + }], + }, + ); + assert_eq!(read(&view, cx, |app| app.marks.len()), 1); + + // Showing a directory opens it; the view follows the agent. + let shown = ask( + cx, + Request::Show { + root: root.clone(), + path: root.join("junk/deeper"), + }, + ); + assert_eq!(shown, Response::Ok); + let (crumbs, expected) = read(&view, cx, |app| { + ( + app.crumbs.clone(), + app.crumbs_for_path(&root.join("junk/deeper")), + ) + }); + assert_eq!(Some(crumbs), expected); + + // Review opens the review screen over the marks. + assert_eq!(ask(cx, Request::Review), Response::Ok); + assert_eq!(read(&view, cx, |app| app.screen), Screen::Review); + assert!( + temp.path().join("junk/blob.bin").exists(), + "nothing removed" + ); + + // Unmarking everything empties the list and leaves the review screen. + let cleared = ask(cx, Request::Unmark { paths: vec![] }); + assert!(matches!(cleared, Response::State(ref s) if s.marks.is_empty())); + assert_eq!(read(&view, cx, |app| app.screen), Screen::Explore); + assert_eq!( + ask(cx, Request::Review), + Response::error("nothing is marked") + ); + + // Hanging up takes the name out of the header. + drop(link); + for _ in 0..400 { + let gone = update(&view, cx, |app, cx| { + app.poll_agent_once(cx); + app.agent + .as_ref() + .is_some_and(|agent| agent.presence.is_none()) + }); + if gone { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + draw(cx); + assert!(cx.debug_bounds("agent-pill").is_none(), "pill gone"); +} + +/// The agent overlay opens from the panel line, lists a command for each +/// client with the binary's own path, and closes on Escape. +#[gpui_kit::test] +fn the_agent_overlay_opens_and_closes(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + assert!(cx.debug_bounds("agent-line").is_some()); + assert!(cx.debug_bounds("agent-overlay").is_none()); + + update(&view, cx, |app, cx| { + app.show_agent = true; + cx.notify(); + }); + draw(cx); + assert!(cx.debug_bounds("agent-overlay").is_some()); + + press(cx, "escape"); + assert!(!read(&view, cx, |app| app.show_agent)); + assert!(cx.debug_bounds("agent-overlay").is_none()); + + // The Help menu opens the same overlay. + cx.dispatch_action(crate::app_menu::ConnectAgent); + draw(cx); + assert!(read(&view, cx, |app| app.show_agent)); + assert!(cx.debug_bounds("agent-overlay").is_some()); + press(cx, "escape"); + + let commands = + crate::agent::connect_commands(Path::new("/opt/disktree/disktree")); + assert_eq!(commands.len(), 3); + assert!( + commands[0] + .1 + .starts_with("claude mcp add --scope user disktree") + ); + assert!( + commands + .iter() + .all(|(_, command)| command.contains("/opt/disktree/disktree")) + ); + assert!(commands[2].1.contains("\"args\": [\"mcp\"]")); +} diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index c23e91b..2f636c9 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -88,6 +88,13 @@ pub fn root( } }, )) + .on_action(cx.listener( + |this, _: &crate::app_menu::ConnectAgent, window, cx| { + this.show_agent = true; + window.focus(&this.focus, cx); + cx.notify(); + }, + )) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { if this.zoom_interface(event, window) { cx.notify(); @@ -111,6 +118,9 @@ pub fn root( if app.show_help { root = root.child(help_overlay(app, cx)); } + if app.show_agent { + root = root.child(agent_overlay(app, cx)); + } if app.volumes_open { root = root.child(volumes_dialog(app, cx)); } @@ -461,6 +471,7 @@ fn top_bar( // Where you are is navigation, and it belongs to the whole window. .child(trail(app, theme, cx)) .child(div().flex_1()) + .children(agent_pill(app, theme, cx)) .child(view_settings(app, window, cx)) } @@ -1352,6 +1363,7 @@ fn side_panel( .children(privacy_line(app, theme, cx)) .children(administrator_line(app, theme, cx)) .child(disk_section(app, theme, cx)) + .child(agent_line(app, theme, cx)) } /// What the keys act on: its name and place, its size set large with its @@ -3631,3 +3643,252 @@ fn help_overlay(app: &Disktree, cx: &gpui_kit::App) -> Div { ), ) } + +/// Who is driving the window, in the top bar: the agent's name and what it +/// last did. Only while an agent is connected; a click opens the overlay. +fn agent_pill( + app: &Disktree, + theme: &Theme, + cx: &Context<'_, Disktree>, +) -> Option> { + let presence = app.agent.as_ref()?.presence.as_ref()?; + Some( + div() + .id("agent-pill") + .debug_selector(|| "agent-pill".into()) + .flex() + .flex_row() + .items_center() + .gap(space::XS) + .px(space::SM) + .py(space::XXS) + .border_1() + .border_color(theme.success.opacity(0.5)) + .text_size(text::CAPTION) + .cursor_pointer() + .hover(|style| style.bg(theme.success.opacity(0.1))) + .on_click(cx.listener(|this, _, window, cx| { + this.show_agent = true; + window.focus(&this.focus, cx); + cx.notify(); + })) + .child(div().text_color(theme.success).child("\u{25cf}")) + .child( + div() + .text_color(theme.foreground) + .whitespace_nowrap() + .child(presence.client.clone()), + ) + .child( + div() + .text_color(theme.secondary) + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .max_w(size::TOOLTIP) + .child(format!("\u{00b7} {}", presence.action)), + ), + ) +} + +/// The way to an agent, under the disk: who is connected, or how to +/// connect one. Quiet, since most sessions never need it. +fn agent_line( + app: &Disktree, + theme: &Theme, + cx: &Context<'_, Disktree>, +) -> Stateful
{ + let presence = app.agent.as_ref().and_then(|agent| agent.presence.clone()); + let mut line = div() + .id("agent-line") + .debug_selector(|| "agent-line".into()) + .flex() + .flex_row() + .items_center() + .gap(space::XS) + .text_size(text::CAPTION) + .cursor_pointer() + .on_click(cx.listener(|this, _, window, cx| { + this.show_agent = true; + window.focus(&this.focus, cx); + cx.notify(); + })); + match presence { + Some(presence) => { + line = line + .child(div().text_color(theme.success).child("\u{25cf}")) + .child( + div() + .text_color(theme.foreground) + .child(format!("{} is connected", presence.client)), + ) + .child(div().flex_1()) + .child( + div() + .text_color(theme.secondary) + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .child(presence.action), + ); + } + None => { + line = line + .text_color(theme.secondary) + .hover(|style| style.text_color(theme.foreground)) + .child("Work with an AI agent \u{203a}"); + } + } + line +} + +/// How to connect an agent, and what it may do once it is: look, mark, +/// and open the review screen. Removing stays with the person. +fn agent_overlay(app: &Disktree, cx: &Context<'_, Disktree>) -> Div { + let theme = cx.omarchy().clone(); + let exe = std::env::current_exe() + .unwrap_or_else(|_| std::path::PathBuf::from("disktree")); + let mut body = div().flex().flex_col().gap(space::MD); + body = body.child( + div() + .text_size(text::BODY) + .text_color(theme.foreground) + .child( + "An agent such as Claude Code or Codex can run disktree as \ + an MCP server: it scans, walks the tree and marks what \ + can go, and this window follows along. Removing stays \ + yours: the agent opens the review screen, and you press \ + Enter.", + ), + ); + for (index, (label, command)) in + crate::agent::connect_commands(&exe).into_iter().enumerate() + { + let what = format!("the {label} command"); + let text = command.clone(); + body = body.child( + div() + .flex() + .flex_col() + .gap(space::XS) + .child(widgets::eyebrow(label, cx)) + .child( + div() + .flex() + .flex_row() + .items_start() + .gap(space::SM) + .child( + div() + .flex_1() + .min_w_0() + .px(space::SM) + .py(space::XS) + .bg(theme.inset) + .border_1() + .border_color(theme.divider()) + .text_size(text::CAPTION) + .text_color(theme.foreground) + .child(command), + ) + .child( + button( + ElementId::Name( + format!("copy-connect-{index}").into(), + ), + "Copy", + ButtonVariant::Outline, + cx, + ) + .tab_stop(false) + .on_click(cx.listener( + move |this, _, window, cx| { + this.copy_connect_text( + text.clone(), + &what, + cx, + ); + window.focus(&this.focus, cx); + }, + )), + ), + ), + ); + } + body = body.child( + div() + .flex() + .flex_row() + .items_center() + .gap(space::SM) + .child( + div() + .flex_1() + .min_w_0() + .text_size(text::CAPTION) + .text_color(theme.secondary) + .child(format!( + "Then ask it: \u{201c}{}\u{201d}", + crate::agent::FIRST_PROMPT + )), + ) + .child( + button("copy-prompt", "Copy", ButtonVariant::Outline, cx) + .tab_stop(false) + .on_click(cx.listener(|this, _, window, cx| { + this.copy_connect_text( + crate::agent::FIRST_PROMPT.to_string(), + "the prompt", + cx, + ); + window.focus(&this.focus, cx); + })), + ), + ); + let status = match &app.agent { + Some(agent) => match &agent.presence { + Some(presence) => format!( + "{} is connected \u{00b7} {}", + presence.client, presence.action + ), + None => "No agent is connected to this window yet.".to_string(), + }, + None => "This window cannot be driven here; an agent still scans \ + and marks on its own." + .to_string(), + }; + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(theme.background.opacity(0.86)) + .child( + div() + .id("agent-overlay") + .debug_selector(|| "agent-overlay".into()) + .flex() + .flex_col() + .gap(space::LG) + .w(size::HELP) + .p(space::XL) + .border_1() + .border_color(theme.border) + .bg(theme.surface) + .child( + div() + .text_size(text::TITLE) + .font_weight(FontWeight::BOLD) + .text_color(theme.bright) + .child("Work with an AI agent"), + ) + .child(body) + .child( + div() + .text_size(text::CAPTION) + .text_color(theme.secondary) + .child(format!("{status} \u{00b7} esc closes")), + ), + ) +} diff --git a/crates/disktree-core/src/classify.rs b/crates/disktree-core/src/classify.rs index bfe24a7..49ec2d6 100644 --- a/crates/disktree-core/src/classify.rs +++ b/crates/disktree-core/src/classify.rs @@ -54,6 +54,30 @@ impl Category { Self::Cache, ]; + /// The label lowercased, as an agent reads and writes it. + pub const fn name(self) -> &'static str { + match self { + Self::Code => "code", + Self::AgentScratch => "agent scratch", + Self::Toolchain => "toolchain", + Self::Synced => "synced", + Self::Git => "git", + Self::Media => "media", + Self::Documents => "documents", + Self::Cache => "cache", + Self::Other => "other", + } + } + + /// The category with this [`Self::name`], if any. + pub fn named(name: &str) -> Option { + Self::LEGEND + .iter() + .copied() + .chain(std::iter::once(Self::Other)) + .find(|category| category.name() == name) + } + pub const fn label(self) -> &'static str { match self { Self::Code => "Code", diff --git a/crates/disktree-core/src/insights.rs b/crates/disktree-core/src/insights.rs index 589029c..de25a0a 100644 --- a/crates/disktree-core/src/insights.rs +++ b/crates/disktree-core/src/insights.rs @@ -28,6 +28,25 @@ pub enum Finding { StaleExperiments { count: usize }, } +impl Finding { + /// The finding in a few words, as a list reads it after the size. + pub fn describe(&self) -> String { + match self { + Self::Reclaimable(reclaim) => { + format!("reclaimable: {}", reclaim.label()) + } + Self::Worktrees { count, oldest_days } => format!( + "{count} agent worktree{}, the oldest {oldest_days} d old", + if *count == 1 { "" } else { "s" } + ), + Self::StaleExperiments { count } => format!( + "{count} experiment{} untouched for a month or more", + if *count == 1 { "" } else { "s" } + ), + } + } +} + /// One line of the list. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Candidate { diff --git a/crates/disktree-core/src/lib.rs b/crates/disktree-core/src/lib.rs index a620d80..5bb6a1b 100644 --- a/crates/disktree-core/src/lib.rs +++ b/crates/disktree-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod insights; mod macos; #[cfg(windows)] mod mft; +pub mod query; pub mod removal; pub mod scan; pub mod scan_threads; diff --git a/crates/disktree-core/src/query.rs b/crates/disktree-core/src/query.rs new file mode 100644 index 0000000..a681d09 --- /dev/null +++ b/crates/disktree-core/src/query.rs @@ -0,0 +1,242 @@ +//! Questions an agent asks of a scanned tree: what is in a directory, and +//! the largest things matching a description. +//! +//! The window answers these over its link and `disktree mcp` answers them +//! over its own scans, so the logic lives here, once. + +use std::path::{Path, PathBuf}; + +use crate::classify::Category; +use crate::tree::{Node, NodeKind}; + +/// What kind of entry a [`Filter`] matches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// Files and directories; a matching directory hides its contents. + #[default] + Any, + File, + Dir, +} + +impl Kind { + pub const fn name(self) -> &'static str { + match self { + Self::Any => "any", + Self::File => "file", + Self::Dir => "dir", + } + } + + pub fn named(name: &str) -> Option { + match name { + "any" => Some(Self::Any), + "file" => Some(Self::File), + "dir" => Some(Self::Dir), + _ => None, + } + } +} + +/// What a search keeps. Every field narrows; the default keeps everything. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Filter { + pub kind: Kind, + /// Only entries of at least this many bytes. + pub min_bytes: u64, + pub category: Option, + /// Only space that can be had back. + pub reclaimable: bool, + /// Only names containing this, compared lowercased. + pub name: Option, + /// Only entries last written before this Unix time. For a directory + /// that is the newest write anywhere beneath it. + pub before: Option, +} + +impl Filter { + pub fn matches(&self, node: &Node) -> bool { + let kind_ok = match self.kind { + Kind::Any => true, + Kind::File => !node.is_dir(), + Kind::Dir => node.is_dir(), + }; + kind_ok + && node.bytes >= self.min_bytes + && self + .category + .is_none_or(|category| node.category == category) + && (!self.reclaimable || node.reclaim.is_some()) + && self + .name + .as_deref() + .is_none_or(|name| node.name.to_lowercase().contains(name)) + && self.before.is_none_or(|before| { + node.modified > 0 && node.modified < before + }) + } + + /// Collect matches beneath `node`, topmost only: a matching directory + /// is listed once, without what is inside it. Sizes shrink going down, + /// so a node below the minimum ends the walk there. + pub fn collect<'a>( + &self, + path: &Path, + node: &'a Node, + found: &mut Vec<(PathBuf, &'a Node)>, + ) { + if node.bytes < self.min_bytes || node.kind == NodeKind::Other { + return; + } + if self.matches(node) { + found.push((path.to_path_buf(), node)); + return; + } + for child in &node.children { + self.collect(&path.join(&*child.name), child, found); + } + } +} + +/// What a search found. +#[derive(Clone, Debug, Default)] +pub struct Found<'a> { + /// The largest matches, largest first, at most the limit asked for. + pub matches: Vec<(PathBuf, &'a Node)>, + /// How many matched in all. + pub count: usize, + /// Bytes of every match, not only the ones listed. + pub bytes: u64, +} + +/// The largest `limit` entries beneath `root` (at `root_path`) that pass +/// `filter`. The root itself is never a match. +pub fn find<'a>( + root_path: &Path, + root: &'a Node, + filter: &Filter, + limit: usize, +) -> Found<'a> { + let mut matches = Vec::new(); + for child in &root.children { + filter.collect(&root_path.join(&*child.name), child, &mut matches); + } + matches.sort_by_key(|(_, node)| std::cmp::Reverse(node.bytes)); + let count = matches.len(); + let bytes = matches.iter().map(|(_, node)| node.bytes).sum(); + matches.truncate(limit); + Found { + matches, + count, + bytes, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classify::Reclaim; + use crate::tree::{Metric, aggregate}; + + fn tree() -> Node { + let mut root = Node::directory("r"); + let mut cache = Node::directory(".cache"); + cache.reclaim = Some(Reclaim::Regenerable); + cache.category = Category::Cache; + let mut blob = Node::entry("blob.bin", NodeKind::File, 400); + blob.modified = 1_000; + cache.children.push(blob); + let mut src = Node::directory("src"); + let mut target = Node::directory("target"); + target.reclaim = Some(Reclaim::BuildOutput); + target + .children + .push(Node::entry("app", NodeKind::File, 300)); + src.children.push(target); + let mut main = Node::entry("main.rs", NodeKind::File, 20); + main.modified = 5_000; + src.children.push(main); + root.children.push(cache); + root.children.push(src); + root.children + .push(Node::entry("Movie.iso", NodeKind::File, 250)); + aggregate(&mut root, Metric::Bytes); + root + } + + #[test] + fn reclaimable_matches_are_topmost_and_largest_first() { + let root = tree(); + let root_path = Path::new("/r"); + let found = find( + root_path, + &root, + &Filter { + reclaimable: true, + ..Filter::default() + }, + 10, + ); + let paths: Vec = + found.matches.iter().map(|(path, _)| path.clone()).collect(); + assert_eq!( + paths, + [ + root_path.join(".cache"), + root_path.join("src").join("target") + ] + ); + assert_eq!(found.count, 2); + assert_eq!(found.bytes, 700); + } + + #[test] + fn files_over_a_size_and_names_and_ages_filter() { + let root = tree(); + let big = find( + Path::new("/r"), + &root, + &Filter { + kind: Kind::File, + min_bytes: 260, + ..Filter::default() + }, + 10, + ); + assert_eq!(big.matches.len(), 2); + assert_eq!(&*big.matches[0].1.name, "blob.bin"); + assert_eq!(&*big.matches[1].1.name, "app"); + + let named = find( + Path::new("/r"), + &root, + &Filter { + name: Some("iso".into()), + ..Filter::default() + }, + 1, + ); + assert_eq!(named.count, 1); + assert_eq!(&*named.matches[0].1.name, "Movie.iso"); + + // Older than 2_000: blob.bin (1_000), not main.rs (5_000), and not + // the unknown-age iso. + let old = find( + Path::new("/r"), + &root, + &Filter { + kind: Kind::File, + before: Some(2_000), + ..Filter::default() + }, + 10, + ); + assert_eq!(old.count, 1); + assert_eq!(&*old.matches[0].1.name, "blob.bin"); + + // The limit trims the list but not the totals. + let one = find(Path::new("/r"), &root, &Filter::default(), 1); + assert_eq!(one.matches.len(), 1); + assert_eq!(one.count, 3); + } +} diff --git a/crates/disktree-core/src/tree.rs b/crates/disktree-core/src/tree.rs index 6991a86..18a2e26 100644 --- a/crates/disktree-core/src/tree.rs +++ b/crates/disktree-core/src/tree.rs @@ -182,6 +182,24 @@ impl Node { self.children.get(index) } + /// The node at `relative`, a path below this one, with the crumbs that + /// lead there. Components are compared one at a time, so a name holding + /// a separator cannot stand for two. + pub fn descend(&self, relative: &Path) -> Option<(Vec, &Self)> { + let mut node = self; + let mut crumbs = Vec::new(); + for component in relative.components() { + let name = component.as_os_str().to_string_lossy(); + let index = node + .children + .iter() + .position(|child| child.name.as_ref() == name)?; + node = &node.children[index]; + crumbs.push(index); + } + Some((crumbs, node)) + } + /// Follow `crumbs` from this node. Crumbs are child indices, so they stay /// valid across re-sorting only for the tree they were produced from. pub fn resolve(&self, crumbs: &[usize]) -> Option<&Self> { diff --git a/crates/disktree-link/Cargo.toml b/crates/disktree-link/Cargo.toml new file mode 100644 index 0000000..282cfc7 --- /dev/null +++ b/crates/disktree-link/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "disktree-link" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "The line protocol between a running disktree window and an agent" + +[lints] +workspace = true + +[dependencies] +disktree-core = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/disktree-link/src/lib.rs b/crates/disktree-link/src/lib.rs new file mode 100644 index 0000000..2e410fa --- /dev/null +++ b/crates/disktree-link/src/lib.rs @@ -0,0 +1,457 @@ +//! The link between a running disktree window and the agent that drives it. +//! +//! `disktree mcp` scans on its own, so an agent needs no window. When one is +//! open, though, the agent's marks should appear in the mosaic and the +//! free-space meter, and its `show` and `review` should move the screen. +//! This crate is the wire between the two: one request per line of JSON +//! over a per-user socket, one line back. +//! +//! When a window is open it is the agent's source of truth: the agent lists +//! and searches the tree the person is looking at, and its marks live in the +//! window. The listing and search logic is [`disktree_core::query`], shared +//! with `disktree mcp`'s own scans, so both answer alike. +//! +//! The socket is the trust boundary, and it is the same one the shell has: +//! only this user can reach it. There is no token and no approval step, by +//! design. The agent cannot remove anything over it; the review screen it +//! opens is where the person decides. + +use std::path::{Path, PathBuf}; + +use disktree_core::classify::Category; +use disktree_core::query::{Filter, Kind}; +use disktree_core::tree::Node; +use serde::{Deserialize, Serialize}; + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::{Incoming, Link, Listener}; + +#[cfg(not(unix))] +mod unsupported; +#[cfg(not(unix))] +pub use unsupported::{Incoming, Link, Listener}; + +/// Protocol version, bumped when a message changes shape. Both sides are +/// built from one tree, so a mismatch means an old window is still open. +pub const VERSION: u32 = 2; + +/// Environment variable naming the socket, for tests and for a second copy +/// of disktree that should not meet the first. +pub const SOCKET_ENV: &str = "DISKTREE_LINK"; + +/// Where the window listens: `$DISKTREE_LINK` if set, else a socket in the +/// user's runtime directory. +pub fn socket_path() -> PathBuf { + socket_path_in( + std::env::var_os(SOCKET_ENV).map(PathBuf::from), + std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), + std::env::var_os("TMPDIR").map(PathBuf::from), + std::env::var_os("USER").map(|user| user.to_string_lossy().into()), + ) +} + +/// [`socket_path`] over given environment values, so the choice is testable. +/// +/// The runtime directory and `$TMPDIR` are already the user's own, so the +/// name needs no uid there; `/tmp` is shared, so the name carries the user. +pub fn socket_path_in( + override_path: Option, + runtime_dir: Option, + tmp_dir: Option, + user: Option, +) -> PathBuf { + if let Some(path) = override_path { + return path; + } + match runtime_dir.or(tmp_dir) { + Some(dir) => dir.join("disktree.sock"), + None => PathBuf::from("/tmp").join(format!( + "disktree-{}.sock", + user.unwrap_or_else(|| "user".into()) + )), + } +} + +/// One path the agent wants marked, with what it measured so the window +/// can show a size before its own scan of that root lands. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarkRequest { + pub path: PathBuf, + pub bytes: u64, + pub is_dir: bool, +} + +/// How a scan should measure, for [`Request::Scan`]. `None` keeps what the +/// window has. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScanOptionsRequest { + pub apparent_size: Option, + pub include_hidden: Option, + pub one_filesystem: Option, + /// `Some(None)` lifts a depth limit; `Some(Some(n))` sets one. + pub max_depth: Option>, +} + +/// A search, as the agent words it; see [`disktree_core::query::Filter`]. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FindRequest { + /// Where to search; the scanned root when absent. + pub path: Option, + /// `any`, `file` or `dir`. + pub kind: String, + pub min_bytes: u64, + /// A [`Category::name`]. + pub category: Option, + pub reclaimable: bool, + pub name: Option, + pub before: Option, + pub limit: usize, +} + +impl FindRequest { + /// The filter this asks for, or why it cannot be one. + pub fn filter(&self) -> Result { + let kind = Kind::named(&self.kind) + .ok_or_else(|| format!("unknown kind {}", self.kind))?; + let category = match &self.category { + Some(name) => Some( + Category::named(name) + .ok_or_else(|| format!("unknown category {name}"))?, + ), + None => None, + }; + Ok(Filter { + kind, + min_bytes: self.min_bytes, + category, + reclaimable: self.reclaimable, + name: self + .name + .as_deref() + .map(str::to_lowercase) + .filter(|name| !name.is_empty()), + before: self.before, + }) + } +} + +/// What the agent asks of the window. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Request { + /// Introduce the agent by its client's name; answered with [`State`]. + Hello { client: String, version: u32 }, + /// What the window shows now; answered with [`State`]. + State, + /// Scan `root`, rescanning when it is the root already. Answered with + /// [`State`] at once; the scan lands when `scanning` turns false. + Scan { + root: PathBuf, + options: ScanOptionsRequest, + }, + /// What is in a directory of the window's tree; answered with + /// [`Listing`]. + Ls { path: PathBuf, limit: usize }, + /// The largest entries matching a description; answered with + /// [`FoundList`]. + Find(FindRequest), + /// Show `path` in the mosaic. `root` is the agent's scan root: when the + /// window shows something else, it rescans from there first. + Show { root: PathBuf, path: PathBuf }, + /// Mark these paths, under the agent's scan `root`. The window measures + /// each from its own tree when it can; `bytes` stands in until then. + /// Already marked paths stay marked; answered with [`Response::Marked`]. + Mark { + root: PathBuf, + targets: Vec, + }, + /// Unmark these paths, or everything when the list is empty; answered + /// with [`State`]. + Unmark { paths: Vec }, + /// Open the review screen over what is marked. + Review, +} + +/// One entry of the tree, as the agent reads it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + pub name: String, + pub is_dir: bool, + pub bytes: u64, + pub files: u64, + /// A [`Category::name`]. + pub category: String, + /// Why its space can be had back, if it can. + pub reclaim: Option, + /// Newest write at or beneath it, Unix seconds; `0` when unknown. + pub modified: i64, + pub read_error: bool, +} + +impl Entry { + pub fn of(node: &Node) -> Self { + Self { + name: node.name.to_string(), + is_dir: node.is_dir(), + bytes: node.bytes, + files: node.files, + category: node.category.name().to_string(), + reclaim: node.reclaim.map(|reclaim| reclaim.label().to_string()), + modified: node.modified, + read_error: node.read_error, + } + } +} + +/// A directory and its largest children. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Listing { + pub path: PathBuf, + pub entry: Entry, + /// The scanned root's total, for the directory's share of it. + pub root_bytes: u64, + /// The scanned root, for naming it. + pub root: PathBuf, + /// Largest first, at most the limit asked for. + pub children: Vec, + /// Children past the limit, and their bytes together. + pub more: usize, + pub more_bytes: u64, +} + +impl Listing { + /// List `node`, which sits at `path` in a tree rooted at `root` whose + /// total is `root_bytes`. + pub fn of( + root: &Path, + root_bytes: u64, + path: &Path, + node: &Node, + limit: usize, + ) -> Self { + let children: Vec = + node.children.iter().take(limit).map(Entry::of).collect(); + let more = node.children.len().saturating_sub(limit); + let more_bytes = node + .children + .iter() + .skip(limit) + .map(|child| child.bytes) + .sum(); + Self { + path: path.to_path_buf(), + entry: Entry::of(node), + root_bytes, + root: root.to_path_buf(), + children, + more, + more_bytes, + } + } +} + +/// One search match, with where it is. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Found { + pub path: PathBuf, + pub entry: Entry, +} + +/// What a search found; see [`disktree_core::query::Found`]. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FoundList { + pub matches: Vec, + pub count: usize, + pub bytes: u64, +} + +impl FoundList { + pub fn of(found: &disktree_core::query::Found<'_>) -> Self { + Self { + matches: found + .matches + .iter() + .map(|(path, node)| Found { + path: path.clone(), + entry: Entry::of(node), + }) + .collect(), + count: found.count, + bytes: found.bytes, + } + } +} + +/// The tree a window has, in totals. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TreeSummary { + pub bytes: u64, + pub files: u64, + pub dirs: u64, + /// Unix seconds when it landed. + pub scanned_at: i64, + pub elapsed_ms: u64, + pub unreadable: u64, + pub apparent_size: bool, + pub include_hidden: bool, +} + +/// One "worth a look" finding. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Insight { + pub path: PathBuf, + pub bytes: u64, + pub what: String, +} + +/// A mark the window would not take, and why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Refused { + pub path: PathBuf, + pub reason: String, +} + +/// A marked path as the window holds it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mark { + pub path: PathBuf, + pub bytes: u64, + pub is_dir: bool, +} + +/// The volume the window measures. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Space { + pub total: u64, + pub free: u64, + pub available: u64, +} + +/// What the last removal did, kept until the next one. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Removal { + /// `trash` or `permanent`. + pub mode: String, + pub removed: usize, + pub failed: usize, + /// Bytes of the targets removed, as marked. + pub bytes: u64, + /// Free space gained on the volume, measured before and after; `None` + /// when the volume could not be read. + pub freed: Option, + /// Unix seconds when it finished. + pub at: i64, +} + +/// What the window shows. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + pub version: u32, + /// The scanned root. + pub root: PathBuf, + /// The directory on screen. + pub current: PathBuf, + /// `explore`, `review`, `running` or `done`. + pub screen: String, + /// A scan is in progress; the tree may be absent or about to change. + pub scanning: bool, + /// A tree is on screen. + pub scanned: bool, + pub marks: Vec, + pub space: Option, + pub removal: Option, + /// The tree on screen, when there is one. + pub tree: Option, + /// The biggest things that could plausibly go, as the panel lists them. + pub insights: Vec, +} + +/// The window's answer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum Response { + State(State), + /// The marks taken, and the ones not. + Marked { + state: State, + refused: Vec, + }, + Listing(Listing), + Found(FoundList), + Ok, + Error { + message: String, + }, +} + +impl Response { + pub fn error(message: impl Into) -> Self { + Self::Error { + message: message.into(), + } + } +} + +/// A line longer than this is not a request; it is a mistake or an attack +/// on memory, and the connection is dropped. +#[cfg(unix)] +const MAX_LINE: usize = 8 * 1024 * 1024; + +#[cfg(unix)] +fn encode(value: &T) -> std::io::Result { + let mut line = + serde_json::to_string(value).map_err(std::io::Error::other)?; + line.push('\n'); + Ok(line) +} + +#[cfg(unix)] +fn decode Deserialize<'de>>(line: &str) -> std::io::Result { + serde_json::from_str(line.trim_end()).map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn requests_round_trip_through_json_lines() { + let request = Request::Mark { + root: PathBuf::from("/home/u"), + targets: vec![MarkRequest { + path: PathBuf::from("/home/u/.cache"), + bytes: 42, + is_dir: true, + }], + }; + let line = encode(&request).expect("encode"); + assert!(line.ends_with('\n')); + assert!(line.contains("\"op\":\"mark\"")); + let back: Request = decode(&line).expect("decode"); + assert_eq!(back, request); + } + + #[test] + fn the_socket_is_the_override_else_a_per_user_place() { + let custom = PathBuf::from("/x/custom.sock"); + assert_eq!( + socket_path_in(Some(custom.clone()), None, None, None), + custom + ); + assert_eq!( + socket_path_in(None, Some("/run/user/1000".into()), None, None), + PathBuf::from("/run/user/1000/disktree.sock") + ); + assert_eq!( + socket_path_in(None, None, Some("/var/t".into()), None), + PathBuf::from("/var/t/disktree.sock") + ); + assert_eq!( + socket_path_in(None, None, None, Some("ann".into())), + PathBuf::from("/tmp/disktree-ann.sock") + ); + } +} diff --git a/crates/disktree-link/src/unix.rs b/crates/disktree-link/src/unix.rs new file mode 100644 index 0000000..92c031d --- /dev/null +++ b/crates/disktree-link/src/unix.rs @@ -0,0 +1,402 @@ +//! The socket on Unix: a listener the window owns, and the client an agent +//! holds. + +use std::collections::VecDeque; +use std::io::{self, BufRead as _, BufReader, Read as _, Write as _}; +use std::os::unix::fs::PermissionsExt as _; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use crate::{MAX_LINE, Request, Response, decode, encode, socket_path}; + +/// How often the accept loop looks for a connection or a stop. Also the +/// most a client waits to be heard; the window drains requests on a similar +/// tick, so this is not the slow part. +const ACCEPT_TICK: Duration = Duration::from_millis(40); + +/// Something a connection sent, or the news that it is gone. +#[derive(Debug)] +pub enum Incoming { + Request { + connection: u64, + request: Request, + reply: Reply, + }, + Closed { + connection: u64, + }, +} + +/// Where a request's answer goes. Dropping it unanswered closes the +/// connection, which the client sees as an error rather than a hang. +#[derive(Debug)] +pub struct Reply(mpsc::Sender); + +impl Reply { + pub fn send(self, response: Response) { + // The connection may have hung up while the window was busy; then + // there is nobody to tell. + let _ = self.0.send(response); + } +} + +/// The window's end: accepts connections in the background and queues what +/// they send for the UI thread to answer on its own schedule. +#[derive(Debug)] +pub struct Listener { + path: PathBuf, + queue: Arc>>, + connections: Arc, + stop: Arc, +} + +impl Listener { + /// Listen at [`socket_path`]. + pub fn bind() -> io::Result { + Self::bind_at(&socket_path()) + } + + /// Listen at `path`, replacing a socket file nothing answers on: the + /// usual leftover of a window that did not exit cleanly. A socket + /// something does answer on is another window's, and this one stays + /// unlinked rather than stealing it. + pub fn bind_at(path: &Path) -> io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let listener = match UnixListener::bind(path) { + Ok(listener) => listener, + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + if UnixStream::connect(path).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "another disktree window is already linked", + )); + } + std::fs::remove_file(path)?; + UnixListener::bind(path)? + } + Err(error) => return Err(error), + }; + // The runtime directory is already the user's, but a socket in + // `$TMPDIR` may not be: no other user gets to drive this window. + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + listener.set_nonblocking(true)?; + + let queue = Arc::new(Mutex::new(VecDeque::new())); + let connections = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let accept = Acceptor { + listener, + queue: Arc::clone(&queue), + connections: Arc::clone(&connections), + stop: Arc::clone(&stop), + next_id: AtomicU64::new(1), + }; + thread::Builder::new() + .name("disktree-link".into()) + .spawn(move || accept.run())?; + Ok(Self { + path: path.to_path_buf(), + queue, + connections, + stop, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Connections open right now. + pub fn connections(&self) -> usize { + self.connections.load(Ordering::Relaxed) + } + + /// Everything that arrived since the last drain, oldest first. + pub fn drain(&self) -> Vec { + let mut queue = self + .queue + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + queue.drain(..).collect() + } +} + +impl Drop for Listener { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + // The file is this window's; a stale one would only make the next + // window take the slow path. + let _ = std::fs::remove_file(&self.path); + } +} + +struct Acceptor { + listener: UnixListener, + queue: Arc>>, + connections: Arc, + stop: Arc, + next_id: AtomicU64, +} + +impl Acceptor { + fn run(self) { + while !self.stop.load(Ordering::Relaxed) { + match self.listener.accept() { + Ok((stream, _)) => self.serve(stream), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(ACCEPT_TICK); + } + // A transient accept error is not worth stopping for; a + // persistent one would spin, so the tick stays in. + Err(_) => thread::sleep(ACCEPT_TICK), + } + } + } + + fn serve(&self, stream: UnixStream) { + // The listener is non-blocking so the loop above can see a stop; + // a connection reads whole lines and wants to block. + if stream.set_nonblocking(false).is_err() { + return; + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let connection = Connection { + id, + stream, + queue: Arc::clone(&self.queue), + connections: Arc::clone(&self.connections), + }; + self.connections.fetch_add(1, Ordering::Relaxed); + if thread::Builder::new() + .name(format!("disktree-link-{id}")) + .spawn(move || connection.run()) + .is_err() + { + self.connections.fetch_sub(1, Ordering::Relaxed); + } + } +} + +struct Connection { + id: u64, + stream: UnixStream, + queue: Arc>>, + connections: Arc, +} + +impl Connection { + fn run(mut self) { + let _ = self.exchange(); + self.connections.fetch_sub(1, Ordering::Relaxed); + self.push(Incoming::Closed { + connection: self.id, + }); + } + + fn exchange(&mut self) -> io::Result<()> { + let mut reader = BufReader::new(self.stream.try_clone()?); + let mut line = String::new(); + loop { + line.clear(); + let read = + (&mut reader).take(MAX_LINE as u64).read_line(&mut line)?; + if read == 0 { + return Ok(()); + } + if !line.ends_with('\n') { + // Longer than any request: not a client worth keeping. + return Err(io::Error::other("request too long")); + } + let response = match decode::(&line) { + Ok(request) => { + let (sender, receiver) = mpsc::channel(); + self.push(Incoming::Request { + connection: self.id, + request, + reply: Reply(sender), + }); + // The window answers when its UI thread gets to it; a + // dropped sender means it never will. + receiver + .recv() + .map_err(|_| io::Error::other("the window went away"))? + } + Err(error) => Response::error(format!("bad request: {error}")), + }; + self.stream.write_all(encode(&response)?.as_bytes())?; + } + } + + fn push(&self, incoming: Incoming) { + let mut queue = self + .queue + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + queue.push_back(incoming); + } +} + +/// The agent's end: one connection, one request at a time. +#[derive(Debug)] +pub struct Link { + stream: UnixStream, + reader: BufReader, +} + +impl Link { + /// Connect to the window at [`socket_path`]. + pub fn connect() -> io::Result { + Self::connect_at(&socket_path()) + } + + pub fn connect_at(path: &Path) -> io::Result { + let stream = UnixStream::connect(path)?; + // The window answers from its UI thread between frames; seconds + // means it is gone, not busy. + stream.set_read_timeout(Some(Duration::from_secs(20)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + let reader = BufReader::new(stream.try_clone()?); + Ok(Self { stream, reader }) + } + + /// Send `request` and wait for the answer. + pub fn call(&mut self, request: &Request) -> io::Result { + self.stream.write_all(encode(request)?.as_bytes())?; + let mut line = String::new(); + let read = (&mut self.reader) + .take(MAX_LINE as u64) + .read_line(&mut line)?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "the window closed the link", + )); + } + decode(&line) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Mark, State, VERSION}; + + fn socket() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("l.sock"); + (dir, path) + } + + fn pump(listener: &Listener) -> Incoming { + for _ in 0..200 { + if let Some(incoming) = listener.drain().into_iter().next() { + return incoming; + } + thread::sleep(Duration::from_millis(5)); + } + panic!("nothing arrived"); + } + + #[test] + fn a_request_reaches_the_queue_and_its_reply_reaches_the_client() { + let (_dir, path) = socket(); + let listener = Listener::bind_at(&path).expect("bind"); + let mut link = Link::connect_at(&path).expect("connect"); + let answer = thread::spawn(move || { + link.call(&Request::Hello { + client: "test".into(), + version: VERSION, + }) + }); + let state = State { + version: VERSION, + root: "/r".into(), + current: "/r".into(), + screen: "explore".into(), + scanning: false, + scanned: true, + marks: vec![Mark { + path: "/r/x".into(), + bytes: 1, + is_dir: false, + }], + space: None, + removal: None, + tree: None, + insights: Vec::new(), + }; + match pump(&listener) { + Incoming::Request { request, reply, .. } => { + assert!(matches!(request, Request::Hello { .. })); + assert_eq!(listener.connections(), 1); + reply.send(Response::State(state.clone())); + } + Incoming::Closed { .. } => panic!("closed first"), + } + assert_eq!( + answer.join().expect("thread").expect("reply"), + Response::State(state) + ); + } + + #[test] + fn hanging_up_is_reported_and_a_stale_socket_is_replaced() { + let (_dir, path) = socket(); + let listener = Listener::bind_at(&path).expect("bind"); + { + let _link = Link::connect_at(&path).expect("connect"); + for _ in 0..200 { + if listener.connections() == 1 { + break; + } + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(listener.connections(), 1); + } + assert!(matches!(pump(&listener), Incoming::Closed { .. })); + drop(listener); + // The file is gone with the listener; put a dead one back and a new + // listener must take its place. + assert!(!path.exists()); + drop(UnixListener::bind(&path).expect("stale")); + assert!(path.exists()); + let again = Listener::bind_at(&path).expect("rebind over stale"); + assert!(Link::connect_at(&path).is_ok()); + drop(again); + } + + #[test] + fn a_live_socket_is_not_stolen() { + let (_dir, path) = socket(); + let first = Listener::bind_at(&path).expect("bind"); + let second = Listener::bind_at(&path); + assert_eq!( + second.err().map(|e| e.kind()), + Some(io::ErrorKind::AddrInUse) + ); + drop(first); + } + + #[test] + fn garbage_gets_an_error_line_not_a_hang() { + let (_dir, path) = socket(); + let listener = Listener::bind_at(&path).expect("bind"); + let mut raw = UnixStream::connect(&path).expect("connect"); + raw.set_read_timeout(Some(Duration::from_secs(5))) + .expect("timeout"); + raw.write_all(b"not json\n").expect("write"); + let mut reader = BufReader::new(raw); + let mut line = String::new(); + reader.read_line(&mut line).expect("read"); + let response: Response = decode(&line).expect("decode"); + assert!(matches!(response, Response::Error { .. })); + drop(listener); + } +} diff --git a/crates/disktree-link/src/unsupported.rs b/crates/disktree-link/src/unsupported.rs new file mode 100644 index 0000000..e243b9c --- /dev/null +++ b/crates/disktree-link/src/unsupported.rs @@ -0,0 +1,85 @@ +//! Off Unix there is no socket yet: the agent works headless, and `show` +//! and `review` say so. A named pipe would bring the window along on +//! Windows; the protocol and these types are ready for it, so the window +//! and the server compile unchanged. + +use std::io; +use std::path::Path; + +use crate::{Request, Response}; + +/// What a connection sent; nothing ever does here. +#[derive(Debug)] +pub enum Incoming { + Request { + connection: u64, + request: Request, + reply: Reply, + }, + Closed { + connection: u64, + }, +} + +/// Where an answer would go. +#[derive(Debug)] +pub struct Reply; + +impl Reply { + #[allow( + clippy::unused_self, + reason = "the Unix reply consumes itself; this one has the same shape" + )] + pub fn send(self, _response: Response) {} +} + +/// A listener that cannot be bound here. +#[derive(Debug)] +pub struct Listener; + +impl Listener { + pub fn bind() -> io::Result { + Err(unsupported()) + } + + pub fn bind_at(_path: &Path) -> io::Result { + Err(unsupported()) + } + + pub fn path(&self) -> &Path { + Path::new("") + } + + pub const fn connections(&self) -> usize { + 0 + } + + pub const fn drain(&self) -> Vec { + Vec::new() + } +} + +/// A client that cannot connect here. +#[derive(Debug)] +pub struct Link; + +impl Link { + pub fn connect() -> io::Result { + Err(unsupported()) + } + + pub fn connect_at(_path: &Path) -> io::Result { + Err(unsupported()) + } + + pub fn call(&mut self, _request: &Request) -> io::Result { + Err(unsupported()) + } +} + +fn unsupported() -> io::Error { + io::Error::new( + io::ErrorKind::Unsupported, + "the window link is not available on this system yet", + ) +} diff --git a/crates/disktree-mcp/Cargo.toml b/crates/disktree-mcp/Cargo.toml new file mode 100644 index 0000000..f374121 --- /dev/null +++ b/crates/disktree-mcp/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "disktree-mcp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "disktree as an MCP server: scan, explore and mark over stdio" + +[lints] +workspace = true + +[dependencies] +disktree-core = { workspace = true } +disktree-link = { workspace = true } +anyhow = { workspace = true } +rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/disktree-mcp/src/lib.rs b/crates/disktree-mcp/src/lib.rs new file mode 100644 index 0000000..a2b00f7 --- /dev/null +++ b/crates/disktree-mcp/src/lib.rs @@ -0,0 +1,39 @@ +//! `disktree mcp`: disktree as a Model Context Protocol server over stdio. +//! +//! An agent runs `disktree mcp` as a subprocess, the same way it would run +//! any other tool, and talks to it over stdin and stdout. There is no port, +//! no token and no sign-in: the agent already runs as this user, and this +//! process can do no more than the user's shell can. It scans with +//! `disktree-core` itself, so no window is needed; when a window is open it +//! follows along over [`disktree_link`], and a window can be opened for the +//! review screen. +//! +//! The agent marks; the person removes. There is no tool that deletes. +//! [`Session`] holds what one connected agent knows; [`server`] is the +//! protocol around it. + +pub mod report; +pub mod server; +pub mod session; + +pub use session::Session; + +/// The free-space procedure, served as a prompt and a resource so the +/// agent can read how to use the tools well before it starts. +pub const SKILL: &str = + include_str!("../../../skills/disktree-free-space/SKILL.md"); + +/// Serve MCP on stdin and stdout until the client hangs up. +pub fn serve() -> anyhow::Result<()> { + use rmcp::ServiceExt as _; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let server = server::Server::new(Session::new()); + let running = server.serve(rmcp::transport::stdio()).await?; + running.waiting().await?; + Ok(()) + }) +} diff --git a/crates/disktree-mcp/src/report.rs b/crates/disktree-mcp/src/report.rs new file mode 100644 index 0000000..97fbe2d --- /dev/null +++ b/crates/disktree-mcp/src/report.rs @@ -0,0 +1,167 @@ +//! How a tree reads as text. +//! +//! An agent reads these answers as tokens, so each is a short, aligned +//! table with the number that matters first, rather than JSON that repeats +//! every key on every row. Sizes are binary units, as in the window. The +//! rows are [`Entry`] values, so a listing reads the same whether it came +//! from the window or from this process's own scan. + +use std::fmt::Write as _; +use std::path::Path; + +use disktree_core::size::{human_bytes, human_count, share, share_bar}; +use disktree_link::{Entry, Found, Listing}; + +/// Width of the share bar on a row. +const BAR: usize = 8; + +/// `~/x` for a path under the home directory, else the path as it is. +pub fn display(path: &Path, home: Option<&Path>) -> String { + match home { + Some(home) if path == home => "~".to_string(), + Some(home) => path.strip_prefix(home).map_or_else( + |_| path.display().to_string(), + |rest| format!("~/{}", rest.display()), + ), + None => path.display().to_string(), + } +} + +/// How long ago `then` was, from `now`, in one unit: `3 d`, `2 mo`. +pub fn age(now: i64, then: i64) -> String { + if then <= 0 { + return "age unknown".to_string(); + } + let seconds = (now - then).max(0); + match seconds { + 0..3_600 => "within the hour".to_string(), + 3_600..86_400 => format!("{} h ago", seconds / 3_600), + 86_400..5_184_000 => format!("{} d ago", seconds / 86_400), + 5_184_000..63_072_000 => format!("{} mo ago", seconds / 2_592_000), + _ => format!("{} y ago", seconds / 31_536_000), + } +} + +/// What an entry is, in one phrase after its name. +pub fn describe(entry: &Entry, now: i64) -> String { + let mut parts = Vec::new(); + if entry.is_dir { + parts.push(format!("{} files", human_count(entry.files))); + } + if entry.category != "other" { + parts.push(entry.category.clone()); + } + if let Some(reclaim) = &entry.reclaim { + parts.push(format!("reclaimable: {reclaim}")); + } + if entry.read_error { + parts.push("unreadable".to_string()); + } + parts.push(age(now, entry.modified)); + parts.join(" \u{00b7} ") +} + +/// One row of a listing: share of `total`, size, name and description. +pub fn row(entry: &Entry, name: &str, total: u64, now: i64) -> String { + let mut name = name.to_string(); + if entry.is_dir { + name.push('/'); + } + format!( + "{} {:>3.0}% {:>9} {name} {}", + share_bar(entry.bytes, total, BAR), + share(entry.bytes, total), + human_bytes(entry.bytes), + describe(entry, now), + ) +} + +/// The children of a listing, largest first, with a line for the rest. +pub fn children(listing: &Listing, now: i64) -> String { + let mut out = String::new(); + let total = listing.entry.bytes.max(1); + for child in &listing.children { + let _ = writeln!(out, "{}", row(child, &child.name, total, now)); + } + if listing.more > 0 { + let _ = writeln!( + out, + "{} {:>3.0}% {:>9} \u{2026} {} more", + share_bar(listing.more_bytes, total, BAR), + share(listing.more_bytes, total), + human_bytes(listing.more_bytes), + listing.more, + ); + } + if listing.children.is_empty() { + out.push_str(if listing.entry.is_dir { + "(empty, or not read)\n" + } else { + "(a file)\n" + }); + } + out +} + +/// Search matches, each with its full path, sharing `total`. +pub fn matches( + found: &[Found], + total: u64, + now: i64, + home: Option<&Path>, +) -> String { + let mut out = String::new(); + for item in found { + let _ = writeln!( + out, + "{}", + row(&item.entry, &display(&item.path, home), total, now) + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use disktree_core::tree::{Metric, Node, NodeKind, aggregate}; + use std::path::PathBuf; + + #[test] + fn paths_under_home_are_shortened() { + let home = PathBuf::from("/home/ann"); + assert_eq!(display(&home, Some(&home)), "~"); + assert_eq!(display(&home.join("src"), Some(&home)), "~/src"); + assert_eq!(display(Path::new("/var"), Some(&home)), "/var"); + assert_eq!(display(Path::new("/var"), None), "/var"); + } + + #[test] + fn ages_pick_one_unit() { + let now = 1_800_000_000; + assert_eq!(age(now, 0), "age unknown"); + assert_eq!(age(now, now - 10), "within the hour"); + assert_eq!(age(now, now - 7_200), "2 h ago"); + assert_eq!(age(now, now - 3 * 86_400), "3 d ago"); + assert_eq!(age(now, now - 70 * 86_400), "2 mo ago"); + assert_eq!(age(now, now - 800 * 86_400), "2 y ago"); + } + + #[test] + fn a_listing_is_largest_first_with_the_tail_summed() { + let mut root = Node::directory("r"); + for (name, bytes) in [("a", 10), ("b", 60), ("c", 30)] { + root.children.push(Node::entry(name, NodeKind::File, bytes)); + } + aggregate(&mut root, Metric::Bytes); + let listing = + Listing::of(Path::new("/r"), root.bytes, Path::new("/r"), &root, 2); + let text = children(&listing, 0); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3); + assert!(lines[0].contains("60 B b "), "{}", lines[0]); + assert!(lines[1].contains("30 B c "), "{}", lines[1]); + assert!(lines[2].contains("1 more"), "{}", lines[2]); + assert!(lines[2].contains("10 B"), "{}", lines[2]); + } +} diff --git a/crates/disktree-mcp/src/server.rs b/crates/disktree-mcp/src/server.rs new file mode 100644 index 0000000..38778dd --- /dev/null +++ b/crates/disktree-mcp/src/server.rs @@ -0,0 +1,324 @@ +//! The MCP surface: eight tools, one prompt, one resource. +//! +//! Each tool is a thin call into [`Session`]; the words an agent reads come +//! from there. Tools that only look are marked read-only, so a client can +//! run them without asking. Nothing here removes a file: marking is not +//! destructive, and the review screen belongs to the person. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use rmcp::handler::server::router::prompt::PromptRouter; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + GetPromptResult, Implementation, ListResourcesResult, PromptMessage, + ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, + Resource, ResourceContents, Role, ServerCapabilities, ServerConfig, +}; +use rmcp::service::{NotificationContext, RequestContext, RoleServer}; +use rmcp::{ + ErrorData, ServerHandler, prompt, prompt_handler, prompt_router, tool, + tool_handler, tool_router, +}; + +use crate::session::{ + FindParams, LsParams, PathsParams, ScanParams, ScanPlan, Session, + ShowParams, run_scan, +}; + +/// How often a scan the window is running is looked in on. +const SCAN_POLL: Duration = Duration::from_millis(500); +/// How long a window scan is followed before the agent is told to come +/// back: a whole disk can take minutes, a tool call should not. +const SCAN_WAIT: Duration = Duration::from_mins(10); + +/// The free-space procedure's address as a resource. +pub const SKILL_URI: &str = "disktree://skills/free-space"; + +/// What the client is told at `initialize`: enough to use the tools well +/// without reading the skill first. +const INSTRUCTIONS: &str = "\ +disktree measures a directory tree the way the disk does: allocated \ +blocks, hardlinks once, hidden entries included. Start with \ +disktree_status, then disktree_scan (no arguments: the home directory; \ +disk: true: the whole volume). Walk with disktree_ls, largest first; \ +disktree_find lists the largest files or every cache, build directory \ +and package store at once. disktree_mark marks paths for removal and \ +returns the plan: what the guards keep back and why, and the free space \ +it would give back on the volume. You never delete: disktree_review \ +opens disktree's review screen, where the user presses Enter to move \ +the marks to the trash. disktree_status afterwards says what was freed. \ +When a disktree window is open its tree is what you read: ls, find and \ +scan are answered from what the user sees, and a path outside its root \ +needs a disktree_scan from higher up, which the window follows. Pass \ +show: true to disktree_ls or call disktree_show to move the view. The \ +prompt free-space and the resource disktree://skills/free-space hold \ +the full procedure."; + +/// The server: one session behind a lock, so the tools can run from any +/// protocol task while scans run off to the side. +#[derive(Clone)] +pub struct Server { + session: Arc>, + tool_router: ToolRouter, + prompt_router: PromptRouter, +} + +impl std::fmt::Debug for Server { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Server").finish_non_exhaustive() + } +} + +impl Server { + pub fn new(session: Session) -> Self { + Self { + session: Arc::new(Mutex::new(session)), + tool_router: Self::tool_router(), + prompt_router: Self::prompt_router(), + } + } + + /// Run `f` with the session. A poisoned lock means a tool panicked; the + /// state is still worth more than nothing. + pub fn with(&self, f: impl FnOnce(&mut Session) -> R) -> R { + let mut session = + self.session.lock().unwrap_or_else(PoisonError::into_inner); + f(&mut session) + } +} + +#[tool_router] +impl Server { + /// What this session knows: scans held, the window if one is open, what + /// is marked, the last removal, and every volume's free space. Start + /// here. + #[tool( + name = "disktree_status", + annotations(title = "disktree status", read_only_hint = true) + )] + async fn status(&self) -> String { + self.with(Session::status) + } + + /// Scan a directory tree and keep the result for the other tools. With + /// no arguments it scans the home directory, which takes seconds; disk: + /// true scans the whole volume. Returns totals, the volume's free space + /// and how much of its used space this scan explains, the top level + /// largest first, and the biggest things that could plausibly go. + /// Scanning the same root again replaces the earlier scan. When a + /// disktree window is open it does the scanning and shows the result; + /// the window's tree is then what every other tool reads. + #[tool( + name = "disktree_scan", + annotations(title = "Scan a directory", read_only_hint = true) + )] + async fn scan( + &self, + Parameters(params): Parameters, + ) -> Result { + match self.with(|s| s.prepare_scan(¶ms))? { + ScanPlan::Local { root, options } => { + let scan = tokio::task::spawn_blocking(move || { + run_scan(root, options) + }) + .await + .map_err(|error| format!("the scan task failed: {error}"))??; + Ok(self.with(|s| s.store_scan(scan))) + } + ScanPlan::Window { root } => { + // The window walks; the session is unlocked between looks + // so disktree_status still answers meanwhile. + let started = Instant::now(); + loop { + if let Some(report) = self.with(|s| s.window_scan(&root))? { + return Ok(report); + } + if started.elapsed() > SCAN_WAIT { + return Err(format!( + "The window is still scanning {} after {} s. \ + disktree_status says when it is done; then \ + disktree_ls reads the tree.", + root.display(), + started.elapsed().as_secs() + )); + } + tokio::time::sleep(SCAN_POLL).await; + } + } + } + } + + /// List a scanned directory, largest first: each entry's share, size, + /// kind of data, whether its space can be had back and why, and when it + /// was last written. show: true moves the disktree window there. + #[tool( + name = "disktree_ls", + annotations(title = "List a directory", read_only_hint = true) + )] + async fn ls( + &self, + Parameters(params): Parameters, + ) -> Result { + self.with(|s| s.ls(¶ms)) + } + + /// The largest entries matching the filters, anywhere beneath a path or + /// every scanned root: files over a size, everything reclaimable + /// (caches, build output, package stores), one kind of data, a name, + /// or what nobody has written to in a while. A matching directory is + /// listed once, without its contents. + #[tool( + name = "disktree_find", + annotations(title = "Find the largest matches", read_only_hint = true) + )] + async fn find( + &self, + Parameters(params): Parameters, + ) -> Result { + self.with(|s| s.find(¶ms)) + } + + /// Show a path in the disktree window, selected in its directory, so + /// the user sees what you are looking at. Opens a window when none is. + #[tool( + name = "disktree_show", + annotations(title = "Show in the window", read_only_hint = true) + )] + async fn show( + &self, + Parameters(params): Parameters, + ) -> Result { + self.with(|s| s.show_path(¶ms)) + } + + /// Mark paths for removal. Nothing is deleted: marks appear in the + /// disktree window when one is open, and the user removes them from the + /// review screen. Returns the plan: what is marked, what the guards + /// keep back and why (the home directory, a mount point, a system + /// tree), what is covered by another mark, and the free space it gives + /// back on the volume. + #[tool( + name = "disktree_mark", + annotations( + title = "Mark for removal", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true + ) + )] + async fn mark( + &self, + Parameters(params): Parameters, + ) -> Result { + self.with(|s| s.mark(¶ms)) + } + + /// Unmark paths, or everything when no paths are given. Returns the + /// plan that remains. + #[tool( + name = "disktree_unmark", + annotations( + title = "Unmark", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true + ) + )] + async fn unmark( + &self, + Parameters(params): Parameters, + ) -> Result { + self.with(|s| s.unmark(¶ms)) + } + + /// Open disktree's review screen over the marks, starting a window if + /// none is open. The user decides there: Enter moves the marks to the + /// trash; permanent deletion always asks first. Call `disktree_status` + /// afterwards for what was freed. + #[tool( + name = "disktree_review", + annotations( + title = "Open the review screen", + read_only_hint = false, + destructive_hint = false + ) + )] + async fn review(&self) -> Result { + self.with(Session::review) + } +} + +#[prompt_router] +impl Server { + /// How to free space with disktree: measure, scan, walk, sweep, + /// classify, mark, and hand the decision back on the review screen. + #[prompt(name = "free-space")] + async fn free_space(&self) -> GetPromptResult { + GetPromptResult::new(vec![PromptMessage::new_text( + Role::User, + crate::SKILL, + )]) + .with_description("Free up disk space with disktree") + } +} + +#[tool_handler(router = self.tool_router)] +#[prompt_handler(router = self.prompt_router)] +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + let capabilities = ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .enable_resources() + .build(); + ServerConfig::new(capabilities) + .with_server_info( + Implementation::new("disktree", env!("CARGO_PKG_VERSION")) + .with_title("disktree") + .with_website_url("https://github.com/tobi/disktree"), + ) + .with_instructions(INSTRUCTIONS) + } + + async fn on_initialized(&self, context: NotificationContext) { + if let Some(info) = context.peer.peer_info() { + let name = info.client_info.name.clone(); + self.with(|s| s.set_client(&name)); + } + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let resource = Resource::new(SKILL_URI, "free-space") + .with_title("Free up space with disktree") + .with_description( + "The procedure: measure, scan, walk, sweep, classify, mark, \ + and hand the decision back on the review screen.", + ) + .with_mime_type("text/markdown"); + Ok(ListResourcesResult::with_all_items(vec![resource])) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + if request.uri != SKILL_URI { + return Err(ErrorData::resource_not_found( + format!("no resource at {}", request.uri), + None, + )); + } + Ok(ReadResourceResult::new(vec![ResourceContents::text( + crate::SKILL, + SKILL_URI, + )]) + .into()) + } +} diff --git a/crates/disktree-mcp/src/session.rs b/crates/disktree-mcp/src/session.rs new file mode 100644 index 0000000..5cf8b79 --- /dev/null +++ b/crates/disktree-mcp/src/session.rs @@ -0,0 +1,2131 @@ +//! What one connected agent knows: the window it is driving, or failing +//! that its own scans, and the marks. +//! +//! When a disktree window is open it is the source of truth: `ls`, `find` +//! and `scan` read the tree the person is looking at, and the marks live +//! there. Without a window this process scans for itself, so an agent in a +//! terminal needs nothing else. Either way the words an agent reads come +//! from here, over `disktree-core` and the link; [`crate::server`] is the +//! thin layer that turns them into MCP tools. + +use std::fmt::Write as _; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use disktree_core::classify::Category; +use disktree_core::insights::{Candidate, worth_a_look}; +use disktree_core::query::{self, Kind}; +use disktree_core::removal::{Target, normalize, plan}; +use disktree_core::scan::{ScanHandle, ScanOptions}; +use disktree_core::size::{human_bytes, human_count, share}; +use disktree_core::space::{ + SpaceInfo, device_for, space_info, volume_root_for, volumes, +}; +use disktree_core::tree::{Node, path_of}; +use disktree_link::{ + FindRequest, FoundList, Insight, Link, Listing, MarkRequest, Request, + Response, ScanOptionsRequest, Space, State, TreeSummary, VERSION, +}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::report::{age, children, describe, display, matches}; + +/// How many findings a scan report lists. +const INSIGHTS: usize = 8; +/// Top-level rows a scan report shows. +const TOP: usize = 12; +/// Rows a listing shows unless asked otherwise. +const DEFAULT_LIMIT: usize = 20; +/// The most rows any listing shows: past this the agent should filter. +const MAX_LIMIT: usize = 200; +/// How long to wait for a window that was just launched to start listening. +const LAUNCH_WAIT: Duration = Duration::from_secs(20); +/// A window that did not answer is not launched again sooner than this: it +/// may still be coming up, and a second one would only compete for the +/// socket. +const RELAUNCH_AFTER: Duration = Duration::from_mins(1); + +/// Unix seconds now. +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs().cast_signed()) +} + +/// Starts a disktree window on a root, for the review screen when none is +/// open. Swapped out in tests. +pub type Launcher = Box io::Result<()> + Send>; + +/// One finished scan of this process's own, kept for the rest of the +/// session. +#[derive(Debug)] +pub struct Scan { + pub root: PathBuf, + pub tree: Arc, + pub options: ScanOptions, + /// Unix seconds when the tree landed. + pub at: i64, + pub elapsed: Duration, + pub unreadable: u64, + pub space: Option, + pub device: Option, + pub insights: Vec, +} + +/// What a scan request amounts to, decided with the session locked. +#[derive(Debug)] +pub enum ScanPlan { + /// Walk here, off the protocol thread, then [`Session::store_scan`]. + Local { root: PathBuf, options: ScanOptions }, + /// The window is scanning `root`; poll [`Session::window_scan`] until + /// it reports. + Window { root: PathBuf }, +} + +/// What `disktree_scan` takes. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ScanParams { + /// Directory to scan. Default: the home directory. + pub path: Option, + /// Scan the whole volume that `path` (or the home directory) is on. + #[serde(default)] + pub disk: bool, + /// Measure apparent length, as `ls -l` shows it, instead of allocated + /// blocks. Blocks are what the disk spends and what deleting frees. + #[serde(default)] + pub apparent_size: bool, + /// Skip dotfiles and dot-directories. Off by default: `~/.cache` is + /// often the largest thing in a home directory. + #[serde(default)] + pub skip_hidden: bool, + /// Also measure other disks, network shares and pseudo filesystems + /// mounted below the root. Off by default: one volume at a time. + #[serde(default)] + pub cross_filesystems: bool, + /// Stop descending past this depth. Totals below it are unknown, which + /// makes a first look at a huge tree cheap. + pub max_depth: Option, +} + +/// What `disktree_ls` takes. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LsParams { + /// The directory to list, under the scanned root. + pub path: PathBuf, + /// How many entries to list, largest first. Default 20, at most 200. + pub limit: Option, + /// Also move the disktree window there, so the user sees what you see. + #[serde(default)] + pub show: bool, +} + +/// What kind of entry `disktree_find` matches; [`Kind`] as a parameter. +#[derive( + Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Eq, +)] +#[serde(rename_all = "lowercase")] +pub enum KindName { + /// Files and directories; a matching directory hides its contents. + #[default] + Any, + File, + Dir, +} + +impl From for Kind { + fn from(name: KindName) -> Self { + match name { + KindName::Any => Self::Any, + KindName::File => Self::File, + KindName::Dir => Self::Dir, + } + } +} + +/// A kind of data, as `disktree_find` filters it; [`Category`] as a +/// parameter. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CategoryName { + Code, + AgentScratch, + Toolchain, + Synced, + Git, + Media, + Documents, + Cache, + Other, +} + +impl From for Category { + fn from(name: CategoryName) -> Self { + match name { + CategoryName::Code => Self::Code, + CategoryName::AgentScratch => Self::AgentScratch, + CategoryName::Toolchain => Self::Toolchain, + CategoryName::Synced => Self::Synced, + CategoryName::Git => Self::Git, + CategoryName::Media => Self::Media, + CategoryName::Documents => Self::Documents, + CategoryName::Cache => Self::Cache, + CategoryName::Other => Self::Other, + } + } +} + +/// What `disktree_find` takes. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FindParams { + /// Where to search. Default: the whole scanned tree. + pub path: Option, + /// `file`, `dir` or `any` (default). A matching directory is listed + /// once, without what is inside it. + pub kind: Option, + /// Only entries of at least this many bytes. + pub min_bytes: Option, + /// Only this kind of data. + pub category: Option, + /// Only space that can be had back: caches, build output, package + /// stores, installed dependencies, sync history, trash, scratch. + #[serde(default)] + pub reclaimable: bool, + /// Only names containing this text, case-insensitively. + pub name: Option, + /// Only entries not written to in this many days. For a directory, the + /// newest write anywhere beneath it counts. + pub older_than_days: Option, + /// How many to list, largest first. Default 25, at most 200. + pub limit: Option, +} + +impl FindParams { + /// The same search as the link words it, with `now` fixing what + /// `older_than_days` means. + fn request(&self, path: Option, now: i64) -> FindRequest { + FindRequest { + path, + kind: Kind::from(self.kind.unwrap_or_default()).name().to_string(), + min_bytes: self.min_bytes.unwrap_or(0), + category: self + .category + .map(|name| Category::from(name).name().to_string()), + reclaimable: self.reclaimable, + name: self.name.clone(), + before: self + .older_than_days + .map(|days| now - i64::from(days) * 86_400), + limit: self.limit.unwrap_or(25).clamp(1, MAX_LIMIT), + } + } +} + +/// What `disktree_mark` and `disktree_unmark` take. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PathsParams { + /// Absolute paths under the scanned root. For `disktree_unmark`, an + /// empty list unmarks everything. + #[serde(default)] + pub paths: Vec, +} + +/// What `disktree_show` takes. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ShowParams { + /// The path to show in the window, under the scanned root. + pub path: PathBuf, +} + +/// One connected agent's state. +pub struct Session { + home: Option, + socket: PathBuf, + launcher: Option, + client: String, + /// This process's own scans, read only while no window is linked. + scans: Vec, + /// Marks as this session knows them: the window's when one is linked, + /// else its own. + marks: Vec, + link: Option, + /// What the window last said about itself. + window: Option, + /// When a window was last started from here, so one that is slow to + /// come up is not started twice. + launched: Option, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("client", &self.client) + .field("scans", &self.scans.len()) + .field("marks", &self.marks.len()) + .field("linked", &self.link.is_some()) + .finish_non_exhaustive() + } +} + +/// Start a disktree window on `root`, detached from this process so the +/// agent's exit does not take the window with it. +fn launch_window(root: &Path) -> io::Result<()> { + let exe = std::env::current_exe()?; + let mut command = Command::new(exe); + command + .arg(root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + // Its own process group: a signal to the agent's group stops here. + command.process_group(0); + } + command.spawn()?; + Ok(()) +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + /// A session over the real socket, able to launch a window. + pub fn new() -> Self { + Self::detached( + disktree_link::socket_path(), + Some(Box::new(launch_window)), + ) + } + + /// A session over `socket`, launching windows with `launcher` or not at + /// all. Tests use this with a stand-in for the window. + pub fn detached(socket: PathBuf, launcher: Option) -> Self { + Self { + home: std::env::home_dir(), + socket, + launcher, + client: "an agent".to_string(), + scans: Vec::new(), + marks: Vec::new(), + link: None, + window: None, + launched: None, + } + } + + /// Remember who is connected, by the name a client gives at + /// `initialize`, in the words the window shows. + pub fn set_client(&mut self, name: &str) { + let lowered = name.to_ascii_lowercase(); + self.client = if lowered.contains("claude-code") + || lowered.contains("claude code") + { + "Claude Code".to_string() + } else if lowered.contains("claude") { + "Claude".to_string() + } else if lowered.contains("codex") { + "Codex".to_string() + } else if name.trim().is_empty() { + "an agent".to_string() + } else { + name.to_string() + }; + } + + pub fn client(&self) -> &str { + &self.client + } + + pub fn scans(&self) -> &[Scan] { + &self.scans + } + + pub fn marks(&self) -> &[Target] { + &self.marks + } + + fn show(&self, path: &Path) -> String { + display(path, self.home.as_deref()) + } + + // ── scanning ──────────────────────────────────────────────────────── + + /// Decide what a scan request means: the root and the options, and + /// whether the window or this process does the walking. With a window + /// linked the scan is asked of it here, and [`Self::window_scan`] + /// follows it; otherwise the walk runs with the session unlocked. + pub fn prepare_scan( + &mut self, + params: &ScanParams, + ) -> Result { + let base = match ¶ms.path { + Some(path) => self.absolute(path)?, + None => self + .home + .clone() + .ok_or("no path given and no home directory")?, + }; + let root = if params.disk { + volume_root_for(&base).unwrap_or_else(|| PathBuf::from("/")) + } else { + base + }; + let metadata = std::fs::metadata(&root).map_err(|error| { + format!("cannot read {}: {error}", root.display()) + })?; + if !metadata.is_dir() { + return Err(format!("{} is not a directory", root.display())); + } + let options = ScanOptions { + apparent_size: params.apparent_size, + include_hidden: !params.skip_hidden, + one_filesystem: !params.cross_filesystems, + max_depth: params.max_depth, + ..ScanOptions::default() + }; + if !self.try_connect() { + return Ok(ScanPlan::Local { root, options }); + } + let request = Request::Scan { + root: root.clone(), + options: ScanOptionsRequest { + apparent_size: Some(options.apparent_size), + include_hidden: Some(options.include_hidden), + one_filesystem: Some(options.one_filesystem), + max_depth: Some(options.max_depth), + }, + }; + match self.call(&request)? { + Response::Error { message } => Err(message), + _ => Ok(ScanPlan::Window { root }), + } + } + + /// Whether the window has finished the scan of `root` it was asked + /// for, and if so what it found. `Ok(None)` is "still scanning". + pub fn window_scan( + &mut self, + root: &Path, + ) -> Result, String> { + let state = match self.call(&Request::State)? { + Response::State(state) => state, + other => return Err(unexpected(&other)), + }; + if state.root != root { + return Err(format!( + "The window moved to {} before the scan of {} finished; \ + the user may have gone elsewhere. Scan again if needed.", + self.show(&state.root), + self.show(root) + )); + } + if state.scanning { + return Ok(None); + } + let Some(tree) = state.tree.clone() else { + return Err(format!( + "The window has no tree for {}: its scan did not finish. \ + The window shows why.", + self.show(root) + )); + }; + let top = match self.call(&Request::Ls { + path: root.to_path_buf(), + limit: TOP, + })? { + Response::Listing(listing) => listing, + Response::Error { message } => return Err(message), + other => return Err(unexpected(&other)), + }; + let facts = ScanFacts { + root: root.to_path_buf(), + tree, + space: state.space, + device: device_for(root), + top, + insights: state.insights, + }; + let mut report = self.scan_report(&facts); + report.push('\n'); + report.push_str(&self.window_line()); + Ok(Some(report)) + } + + /// Keep a finished scan of this process's own, replacing an earlier one + /// of the same root, and say what it found. + pub fn store_scan(&mut self, scan: Scan) -> String { + self.scans.retain(|known| known.root != scan.root); + let report = self.scan_report(&ScanFacts::of_local(&scan)); + self.scans.push(scan); + report + } + + fn scan_report(&self, facts: &ScanFacts) -> String { + let now = now(); + let tree = &facts.tree; + let mut out = String::new(); + let _ = write!( + out, + "Scanned {} in {:.1} s: {} \u{00b7} {} files \u{00b7} {} \ + directories", + self.show(&facts.root), + Duration::from_millis(tree.elapsed_ms).as_secs_f64(), + human_bytes(tree.bytes), + human_count(tree.files), + human_count(tree.dirs), + ); + if tree.unreadable > 0 { + let _ = write!(out, " \u{00b7} {} unreadable", tree.unreadable); + } + if tree.apparent_size { + out.push_str(" \u{00b7} apparent sizes"); + } + if !tree.include_hidden { + out.push_str(" \u{00b7} hidden entries skipped"); + } + out.push('\n'); + if let Some(space) = facts.space { + let used = space.total.saturating_sub(space.free); + let _ = writeln!( + out, + "Volume {}{}: {} free of {}, {:.0}% used. This scan holds \ + {:.0}% of what is used; the rest is outside {} or is not \ + files (snapshots, other volumes, swap).", + volume_root_for(&facts.root).map_or_else( + || "?".to_string(), + |root| root.display().to_string() + ), + facts + .device + .as_deref() + .map_or_else(String::new, |device| format!(" ({device})")), + human_bytes(space.available), + human_bytes(space.total), + share(used, space.total.max(1)), + share(tree.bytes, used.max(1)), + self.show(&facts.root), + ); + } + out.push_str("Top level, largest first:\n"); + out.push_str(&children(&facts.top, now)); + if !facts.insights.is_empty() { + out.push_str( + "Worth a look, the biggest things that could plausibly go:\n", + ); + for insight in &facts.insights { + let _ = writeln!( + out, + " {:>9} {} {}", + human_bytes(insight.bytes), + self.show(&insight.path), + insight.what, + ); + } + } + out.push_str( + "Next: disktree_ls goes into a directory, disktree_find lists \ + the largest files or every cache, disktree_mark marks what \ + should go.", + ); + out + } + + // ── looking ───────────────────────────────────────────────────────── + + /// An absolute, normalized path: `~` expanded, relative to the working + /// directory otherwise. + fn absolute(&self, path: &Path) -> Result { + let expanded = if let Ok(rest) = path.strip_prefix("~") { + let home = self.home.clone().ok_or("no home directory for ~")?; + home.join(rest) + } else if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| format!("no working directory: {error}"))? + .join(path) + }; + Ok(normalize(&expanded)) + } + + /// The scan of this process's own holding `path`, with the node there. + fn locate(&self, path: &Path) -> Result, String> { + let path = self.absolute(path)?; + if self.scans.is_empty() { + return Err( + "Nothing is scanned yet. Call disktree_scan first; with no \ + arguments it scans the home directory." + .to_string(), + ); + } + let scan = self + .scans + .iter() + .filter(|scan| path.starts_with(&scan.root)) + .max_by_key(|scan| scan.root.as_os_str().len()) + .ok_or_else(|| { + let roots: Vec = self + .scans + .iter() + .map(|scan| self.show(&scan.root)) + .collect(); + format!( + "{} is outside the scanned roots ({}). Scan a directory \ + that contains it.", + self.show(&path), + roots.join(", ") + ) + })?; + let relative = path + .strip_prefix(&scan.root) + .map_err(|_| "unreachable: prefix was checked".to_string())?; + let (crumbs, node) = scan.tree.descend(relative).ok_or_else(|| { + format!( + "{} is not in the scan of {}: it may be new, hidden, below \ + the depth limit, or on another filesystem. Scan again if \ + it should be there.", + self.show(&path), + self.show(&scan.root) + ) + })?; + Ok(Located { + scan, + path, + crumbs, + node, + }) + } + + /// The window's root when it covers `path`; otherwise why the agent + /// should scan from higher up. Only meaningful while linked. + fn window_root_for(&self, path: &Path) -> Result { + let Some(state) = &self.window else { + return Err("the window has not said where it is".to_string()); + }; + if path.starts_with(&state.root) { + return Ok(state.root.clone()); + } + Err(format!( + "{} is outside the disktree window's tree, which is rooted at \ + {}. The window is what the user sees, so it is what you read: \ + call disktree_scan on a directory containing it and the window \ + follows.", + self.show(path), + self.show(&state.root) + )) + } + + /// A directory and its largest children, from the window or from a + /// scan of this process's own. + fn listing( + &mut self, + path: &Path, + limit: usize, + ) -> Result { + if self.try_connect() { + self.window_root_for(path)?; + return match self.call(&Request::Ls { + path: path.to_path_buf(), + limit, + })? { + Response::Listing(listing) => Ok(listing), + Response::Error { message } => Err(message), + other => Err(unexpected(&other)), + }; + } + let found = self.locate(path)?; + Ok(Listing::of( + &found.scan.root, + found.scan.tree.bytes, + &found.path, + found.node, + limit, + )) + } + + /// List a directory, largest first. + pub fn ls(&mut self, params: &LsParams) -> Result { + let limit = params.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + let now = now(); + let path = self.absolute(¶ms.path)?; + let listing = self.listing(&path, limit)?; + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + self.show(&listing.path), + describe(&listing.entry, now) + ); + if listing.path != listing.root { + let _ = writeln!( + out, + "{:.0}% of {}, which is {}", + share(listing.entry.bytes, listing.root_bytes), + self.show(&listing.root), + human_bytes(listing.root_bytes) + ); + } + out.push_str(&children(&listing, now)); + if params.show { + out.push_str(&self.show_in_window(&listing.root, &path)?); + } + Ok(out) + } + + /// What a search finds, from the window or from the scans here. + fn found(&mut self, request: &FindRequest) -> Result { + if self.try_connect() { + if let Some(path) = &request.path { + self.window_root_for(path)?; + } + return match self.call(&Request::Find(request.clone()))? { + Response::Found(found) => Ok(found), + Response::Error { message } => Err(message), + other => Err(unexpected(&other)), + }; + } + let filter = request.filter()?; + let scope: Vec<(PathBuf, &Node)> = if let Some(path) = &request.path { + let located = self.locate(path)?; + vec![(located.path, located.node)] + } else { + if self.scans.is_empty() { + return Err( + "Nothing is scanned yet. Call disktree_scan first." + .to_string(), + ); + } + self.scans + .iter() + .map(|scan| (scan.root.clone(), &*scan.tree)) + .collect() + }; + let mut all = FoundList::default(); + for (path, node) in &scope { + let found = + FoundList::of(&query::find(path, node, &filter, request.limit)); + all.matches.extend(found.matches); + all.count += found.count; + all.bytes += found.bytes; + } + all.matches + .sort_by_key(|found| std::cmp::Reverse(found.entry.bytes)); + all.matches.truncate(request.limit); + Ok(all) + } + + /// The largest entries matching the filters, topmost matches only. + pub fn find(&mut self, params: &FindParams) -> Result { + let now = now(); + let path = match ¶ms.path { + Some(path) => Some(self.absolute(path)?), + None => None, + }; + let request = params.request(path.clone(), now); + let found = self.found(&request)?; + let where_ = match (&path, &self.window) { + (Some(path), _) => self.show(path), + (None, Some(state)) if self.link.is_some() => { + self.show(&state.root) + } + (None, _) => "the scanned roots".to_string(), + }; + let mut out = String::new(); + if found.count == 0 { + let _ = writeln!(out, "Nothing under {where_} matches."); + return Ok(out); + } + let _ = writeln!( + out, + "{} match{} under {where_}, {} in all; the largest {}:", + found.count, + if found.count == 1 { "" } else { "es" }, + human_bytes(found.bytes), + found.matches.len(), + ); + out.push_str(&matches( + &found.matches, + found.bytes, + now, + self.home.as_deref(), + )); + Ok(out) + } + + // ── the window ────────────────────────────────────────────────────── + + /// Whether the window link could be opened without launching anything. + /// A new link learns the window's state and is handed the marks made + /// here before it existed; from then on the window's marks are the + /// marks. + fn try_connect(&mut self) -> bool { + if self.link.is_some() { + return true; + } + let Ok(link) = Link::connect_at(&self.socket) else { + return false; + }; + self.link = Some(link); + // `hello` adopts the window's marks, which would lose these. + let mine = std::mem::take(&mut self.marks); + let request = Request::Hello { + client: self.client.clone(), + version: VERSION, + }; + if self.call(&request).is_err() { + self.marks = mine; + return false; + } + if self.push_marks(&mine).is_err() { + return false; + } + true + } + + /// Open the link, launching a window on `root` when none answers. + fn ensure_link(&mut self, root: &Path) -> Result<(), String> { + if self.try_connect() { + return Ok(()); + } + let Some(launcher) = &self.launcher else { + return Err(format!( + "No disktree window is open, and this session cannot open \ + one here ({}). Start `disktree {}` yourself, then call \ + again.", + Link::connect_at(&self.socket) + .err() + .map_or_else(String::new, |error| error.to_string()), + root.display() + )); + }; + if let Some(launched) = self.launched + && launched.elapsed() < RELAUNCH_AFTER + { + return Err(format!( + "A disktree window was started {} s ago and has not \ + answered yet. Give it a moment and call again; if none \ + appeared, start `disktree {}` by hand.", + launched.elapsed().as_secs(), + root.display() + )); + } + launcher(root).map_err(|error| { + format!("could not start a disktree window: {error}") + })?; + let started = Instant::now(); + self.launched = Some(started); + while started.elapsed() < LAUNCH_WAIT { + std::thread::sleep(Duration::from_millis(100)); + if self.try_connect() { + self.launched = None; + return Ok(()); + } + } + Err("A disktree window was started but did not answer in time. \ + Call again once it is open." + .to_string()) + } + + /// Send one request. Every answer that carries the window's state is + /// adopted. A failed link is dropped, so the next call tries again + /// from the start. + fn call(&mut self, request: &Request) -> Result { + let Some(link) = &mut self.link else { + return Err("the window is not linked".to_string()); + }; + match link.call(request) { + Ok(response) => { + if let Response::State(state) | Response::Marked { state, .. } = + &response + { + self.adopt(state); + } + Ok(response) + } + Err(error) => { + self.link = None; + self.window = None; + Err(format!("the disktree window went away: {error}")) + } + } + } + + /// The window's marks are the marks: keep ours in step. + fn adopt(&mut self, state: &State) { + self.marks = state + .marks + .iter() + .map(|mark| Target { + hidden: is_hidden(&mark.path), + path: mark.path.clone(), + bytes: mark.bytes, + is_dir: mark.is_dir, + }) + .collect(); + self.window = Some(state.clone()); + } + + /// Hand marks made before the window linked over to it. Each answer + /// carries the window's marks, which replace ours. + fn push_marks(&mut self, mine: &[Target]) -> Result<(), String> { + for root in self.roots_of(mine) { + let targets: Vec = mine + .iter() + .filter(|target| target.path.starts_with(&root)) + .map(|target| MarkRequest { + path: target.path.clone(), + bytes: target.bytes, + is_dir: target.is_dir, + }) + .collect(); + self.call(&Request::Mark { + root: root.clone(), + targets, + })?; + } + Ok(()) + } + + /// Show `path` in the window, opening one on `root` if needed. + fn show_in_window( + &mut self, + root: &Path, + path: &Path, + ) -> Result { + let path = self.absolute(path)?; + self.ensure_link(root)?; + match self.call(&Request::Show { + root: root.to_path_buf(), + path: path.clone(), + })? { + Response::Error { message } => Err(message), + _ => Ok(format!("Window: showing {}.\n", self.show(&path))), + } + } + + pub fn show_path(&mut self, params: &ShowParams) -> Result { + let path = self.absolute(¶ms.path)?; + let root = if self.try_connect() { + // A path outside the window but inside a scan held here moves + // the window to that scan's root; one in neither is an error. + match self.window_root_for(&path) { + Ok(root) => root, + Err(outside) => self + .locate(&path) + .map(|found| found.scan.root.clone()) + .map_err(|_| outside)?, + } + } else { + self.locate(&path)?.scan.root.clone() + }; + self.show_in_window(&root, &path) + } + + /// What the window is doing, in one line, for the end of a report. + fn window_line(&mut self) -> String { + // A link that was already open may be stale; ask again. + if self.try_connect() { + let _ = self.call(&Request::State); + } + match &self.window { + Some(state) => { + let tree = match &state.tree { + Some(tree) => format!( + " ({} \u{00b7} {} files \u{00b7} scanned {})", + human_bytes(tree.bytes), + human_count(tree.files), + age(now(), tree.scanned_at) + ), + None => String::new(), + }; + format!( + "Window: open on {}{tree}, {} screen{}{}. Its tree is \ + what disktree_ls and disktree_find read.", + self.show(&state.root), + state.screen, + if state.scanning { ", scanning" } else { "" }, + match state.marks.len() { + 0 => String::new(), + 1 => ", 1 mark".to_string(), + n => format!(", {n} marks"), + }, + ) + } + None if self.launcher.is_some() => { + "Window: none open. disktree_review opens one on the marks \ + for the user to confirm; disktree_show opens one on a path." + .to_string() + } + None => "Window: none open, and none can be opened from here." + .to_string(), + } + } + + // ── marking ───────────────────────────────────────────────────────── + + /// Mark paths for removal, in the window when one is open. + pub fn mark(&mut self, params: &PathsParams) -> Result { + if params.paths.is_empty() { + return Err("give at least one path to mark".to_string()); + } + let mut paths = Vec::new(); + for path in ¶ms.paths { + paths.push(self.absolute(path)?); + } + let refused = if self.try_connect() { + self.mark_in_window(&paths)? + } else { + self.mark_here(&paths)? + }; + let mut report = String::new(); + if !refused.is_empty() { + let _ = writeln!(report, "Not marked:"); + for reason in &refused { + let _ = writeln!(report, " {reason}"); + } + } + report.push_str(&self.plan_report()); + Ok(report) + } + + /// Mark in the window, which measures each path from its own tree. + /// Returns what was refused, or an error when nothing could be marked. + fn mark_in_window( + &mut self, + paths: &[PathBuf], + ) -> Result, String> { + let mut refused = Vec::new(); + let mut targets = Vec::new(); + let mut root = None; + for path in paths { + let window_root = match self.window_root_for(path) { + Ok(root) => root, + Err(reason) => { + refused.push(reason); + continue; + } + }; + if *path == window_root { + refused.push(format!( + "{} is the scanned root; mark what is inside it instead", + self.show(path) + )); + continue; + } + // A scan held here may know the size already; the window + // measures regardless. + let (bytes, is_dir) = self + .locate(path) + .map_or((0, false), |f| (f.node.bytes, f.node.is_dir())); + targets.push(MarkRequest { + path: path.clone(), + bytes, + is_dir, + }); + root = Some(window_root); + } + let Some(root) = root else { + return Err(refused.join("\n")); + }; + match self.call(&Request::Mark { root, targets })? { + Response::Marked { refused: more, .. } => { + for item in more { + // The window's reason usually names the path already. + let shown = self.show(&item.path); + refused.push(if item.reason.contains(&shown) { + item.reason + } else { + format!("{shown}: {}", item.reason) + }); + } + } + Response::State(_) => {} + Response::Error { message } => return Err(message), + other => return Err(unexpected(&other)), + } + if self.marks.is_empty() && !refused.is_empty() { + return Err(refused.join("\n")); + } + Ok(refused) + } + + /// Mark against this process's own scans, with no window to ask. + fn mark_here(&mut self, paths: &[PathBuf]) -> Result, String> { + let mut refused = Vec::new(); + let mut targets = Vec::new(); + for path in paths { + let found = match self.locate(path) { + Ok(found) => found, + Err(reason) => { + refused.push(reason); + continue; + } + }; + if found.crumbs.is_empty() { + refused.push(format!( + "{} is the scanned root; mark what is inside it instead", + self.show(&found.path) + )); + continue; + } + targets.push(Target { + hidden: is_hidden(&found.path), + is_dir: found.node.is_dir(), + bytes: found.node.bytes, + path: found.path, + }); + } + if targets.is_empty() { + return Err(refused.join("\n")); + } + for target in targets { + if !self.marks.iter().any(|known| known.path == target.path) { + self.marks.push(target); + } + } + Ok(refused) + } + + /// Unmark paths, or everything. + pub fn unmark(&mut self, params: &PathsParams) -> Result { + let mut paths = Vec::new(); + for path in ¶ms.paths { + paths.push(self.absolute(path)?); + } + if self.try_connect() { + self.call(&Request::Unmark { paths })?; + } else if paths.is_empty() { + self.marks.clear(); + } else { + self.marks.retain(|target| !paths.contains(&target.path)); + } + Ok(self.plan_report()) + } + + /// The scan roots the marks fall under, each once: the window's root + /// when it covers a mark, else a scan's here, else the mark's parent. + fn roots_of(&self, marks: &[Target]) -> Vec { + let mut roots: Vec = Vec::new(); + for mark in marks { + let root = self + .window + .as_ref() + .filter(|state| mark.path.starts_with(&state.root)) + .map(|state| state.root.clone()) + .or_else(|| { + self.scans + .iter() + .filter(|scan| mark.path.starts_with(&scan.root)) + .max_by_key(|scan| scan.root.as_os_str().len()) + .map(|scan| scan.root.clone()) + }) + .unwrap_or_else(|| { + mark.path + .parent() + .map_or_else(|| mark.path.clone(), Path::to_path_buf) + }); + if !roots.contains(&root) { + roots.push(root); + } + } + roots + } + + /// What is marked, what the guards keep back, and what it frees. + pub fn plan_report(&mut self) -> String { + let mut out = String::new(); + if self.marks.is_empty() { + out.push_str("Nothing is marked.\n"); + out.push_str(&self.window_line()); + return out; + } + let total: u64 = self.marks.iter().map(|mark| mark.bytes).sum(); + let _ = writeln!( + out, + "Marked {} \u{00b7} {}", + self.marks.len(), + human_bytes(total) + ); + let marks = self.marks.clone(); + for root in self.roots_of(&marks) { + let mine: Vec = marks + .iter() + .filter(|mark| mark.path.starts_with(&root)) + .cloned() + .collect(); + out.push_str(&self.plan_for(&mine, &root)); + } + out.push_str(&self.window_line()); + out + } + + /// The plan for the marks under one root: targets, what another mark + /// covers, what the guards keep back, and the space it frees. + fn plan_for(&self, mine: &[Target], root: &Path) -> String { + let mut out = String::new(); + let plan = plan(mine, root); + for target in &plan.targets { + let _ = writeln!( + out, + " {:>9} {}{}", + human_bytes(target.bytes), + self.show(&target.path), + if target.is_dir { "/" } else { "" }, + ); + } + if !plan.covered.is_empty() { + let _ = writeln!( + out, + "Covered by another mark ({}): {}", + plan.covered.len(), + plan.covered + .iter() + .map(|target| self.show(&target.path)) + .collect::>() + .join(", ") + ); + } + if !plan.blocked.is_empty() { + let _ = writeln!(out, "Kept back ({}):", plan.blocked.len()); + for blocked in &plan.blocked { + let _ = writeln!( + out, + " {} {}", + self.show(&blocked.path), + blocked.reason + ); + } + } + match space_info(root).ok() { + Some(space) => { + let after = space.after_removing(plan.reclaim()); + let _ = writeln!( + out, + "Frees {} on {}: {} free now, {} after.", + human_bytes(plan.reclaim()), + device_for(root).unwrap_or_else(|| self.show(root)), + human_bytes(space.available), + human_bytes(after.available), + ); + } + None => { + let _ = writeln!( + out, + "Frees {} on the volume of {}.", + human_bytes(plan.reclaim()), + self.show(root) + ); + } + } + if plan.foreign() > 0 { + let _ = writeln!( + out, + "Plus {} on other volumes, not counted above.", + human_bytes(plan.foreign()) + ); + } + if plan.unattributed() > 0 { + let _ = writeln!( + out, + "{} whose volume cannot be told is not counted either.", + human_bytes(plan.unattributed()) + ); + } + out + } + + /// Open the review screen in the window, launching one if needed. + pub fn review(&mut self) -> Result { + if self.marks.is_empty() { + return Err("Nothing is marked. disktree_mark some paths first." + .to_string()); + } + let marks = self.marks.clone(); + let root = self + .roots_of(&marks) + .into_iter() + .next() + .ok_or("no root for the marks")?; + self.ensure_link(&root)?; + if let Response::Error { message } = self.call(&Request::Review)? { + return Err(message); + } + let _ = self.call(&Request::State); + let total: u64 = self.marks.iter().map(|mark| mark.bytes).sum(); + Ok(format!( + "The review screen is open in disktree: {} marked, {} to free. \ + The user decides there. Enter moves the marks to the trash, \ + where they can be recovered; permanent deletion is a choice \ + away and always asks first. Afterwards disktree_status reports \ + what was actually freed.", + self.marks.len(), + human_bytes(total) + )) + } + + // ── status ────────────────────────────────────────────────────────── + + /// Everything this session knows, in a screenful. + pub fn status(&mut self) -> String { + let now = now(); + let mut out = String::new(); + let _ = writeln!( + out, + "disktree {} serving {} over MCP. The agent marks; the person \ + removes on the review screen.", + env!("CARGO_PKG_VERSION"), + self.client + ); + out.push_str(&self.window_line()); + out.push('\n'); + if self.scans.is_empty() { + if self.link.is_none() { + out.push_str( + "Scans: none yet. disktree_scan with no arguments scans \ + the home directory in seconds; disk: true scans the \ + volume.\n", + ); + } + } else { + out.push_str(if self.link.is_some() { + "Scans held here, unused while the window is open:\n" + } else { + "Scans:\n" + }); + for scan in &self.scans { + let _ = writeln!( + out, + " {} {} \u{00b7} {} files \u{00b7} {} \u{00b7} took \ + {:.1} s", + self.show(&scan.root), + human_bytes(scan.tree.bytes), + human_count(scan.tree.files), + age(now, scan.at), + scan.elapsed.as_secs_f64(), + ); + } + } + if let Some(removal) = + self.window.as_ref().and_then(|state| state.removal.clone()) + { + let _ = writeln!( + out, + "Last removal: {} \u{00b7} {} removed \u{00b7} {} failed \ + \u{00b7} {} marked \u{00b7} {} \u{00b7} {}", + removal.mode, + removal.removed, + removal.failed, + human_bytes(removal.bytes), + removal.freed.map_or_else( + || "freed space not measured".to_string(), + |freed| format!( + "{} freed on the volume", + human_bytes(freed) + ) + ), + age(now, removal.at), + ); + } + if self.marks.is_empty() { + out.push_str("Marked: nothing.\n"); + } else { + let total: u64 = self.marks.iter().map(|mark| mark.bytes).sum(); + let _ = writeln!( + out, + "Marked: {} \u{00b7} {} (disktree_mark with no new paths, or \ + disktree_unmark, shows the plan)", + self.marks.len(), + human_bytes(total) + ); + } + out.push_str(&volumes_report()); + out + } +} + +/// Every volume a person would call a disk, fullest first. +fn volumes_report() -> String { + let mut out = String::new(); + let volumes: Vec<_> = volumes().into_iter().filter(worth_listing).collect(); + if volumes.is_empty() { + return out; + } + out.push_str("Volumes, fullest first:\n"); + for volume in volumes.iter().take(8) { + match volume.space { + Some(space) => { + let _ = writeln!( + out, + " {} {} free of {} ({:.0}% used){}", + volume.point.display(), + human_bytes(space.available), + human_bytes(space.total), + f64::from(space.used_fraction()) * 100.0, + volume + .device + .as_deref() + .map_or_else(String::new, |device| format!( + " {device}" + )), + ); + } + None => { + let _ = writeln!( + out, + " {} free space unknown", + volume.point.display() + ); + } + } + } + out +} + +/// Whether a volume is one a person would call a disk: the system's own +/// slices, device files, simulator images and automount stubs are left +/// out, as is anything under a gigabyte. +fn worth_listing(volume: &disktree_core::space::Volume) -> bool { + const GIB: u64 = 1024 * 1024 * 1024; + let point = volume.point.to_string_lossy(); + let system = [ + "/System/Volumes/", + "/private/", + "/dev", + "/Library/Developer/CoreSimulator/", + "/proc", + "/sys", + "/run", + "/boot/efi", + "/snap/", + ] + .iter() + .any(|prefix| point.starts_with(prefix)); + !system + && volume + .device + .as_deref() + .is_none_or(|device| !device.starts_with("map ")) + && volume.space.is_some_and(|space| space.total >= GIB) +} + +/// Where a path landed in a scan held here. +struct Located<'a> { + scan: &'a Scan, + path: PathBuf, + crumbs: Vec, + node: &'a Node, +} + +/// What a scan report is made of, whichever side did the walking. +struct ScanFacts { + root: PathBuf, + tree: TreeSummary, + space: Option, + device: Option, + top: Listing, + insights: Vec, +} + +impl ScanFacts { + fn of_local(scan: &Scan) -> Self { + Self { + root: scan.root.clone(), + tree: TreeSummary { + bytes: scan.tree.bytes, + files: scan.tree.files, + dirs: u64::from(scan.tree.dirs), + scanned_at: scan.at, + elapsed_ms: u64::try_from(scan.elapsed.as_millis()) + .unwrap_or(u64::MAX), + unreadable: scan.unreadable, + apparent_size: scan.options.apparent_size, + include_hidden: scan.options.include_hidden, + }, + space: scan.space.map(|space| Space { + total: space.total, + free: space.free, + available: space.available, + }), + device: scan.device.clone(), + top: Listing::of( + &scan.root, + scan.tree.bytes, + &scan.root, + &scan.tree, + TOP, + ), + insights: scan + .insights + .iter() + .map(|candidate| Insight { + path: path_of(&scan.root, &scan.tree, &candidate.crumbs), + bytes: candidate.bytes, + what: candidate.finding.describe(), + }) + .collect(), + } + } +} + +/// A dotfile or dot-directory by name. +fn is_hidden(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) +} + +/// An answer of a shape the request does not take: an old window. +fn unexpected(response: &Response) -> String { + format!( + "the window answered with something unexpected ({}); restart it", + match response { + Response::State(_) => "state", + Response::Marked { .. } => "marked", + Response::Listing(_) => "listing", + Response::Found(_) => "found", + Response::Ok => "ok", + Response::Error { .. } => "error", + } + ) +} + +/// Walk `root` with `options` and keep what is needed later. Blocking; +/// the server runs it off the protocol thread. +pub fn run_scan(root: PathBuf, options: ScanOptions) -> Result { + let started = Instant::now(); + let handle = ScanHandle::spawn(root.clone(), options.clone()); + let outcome = loop { + if let Some(outcome) = handle.poll() { + break outcome; + } + std::thread::sleep(Duration::from_millis(50)); + }; + let tree = outcome.map_err(|error| { + format!("scan of {} failed: {error}", root.display()) + })?; + let snapshot = handle.progress.snapshot(); + let at = now(); + let insights = worth_a_look(&tree, at, INSIGHTS); + Ok(Scan { + space: space_info(&root).ok(), + device: device_for(&root), + root, + tree: Arc::new(tree), + options, + at, + elapsed: started.elapsed(), + unreadable: snapshot.errors, + insights, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use disktree_link::{Incoming, Listener, Mark, Refused, Removal}; + #[cfg(unix)] + use std::sync::Mutex; + #[cfg(unix)] + use std::sync::atomic::{AtomicBool, Ordering}; + #[cfg(unix)] + use std::thread; + + /// A small tree on disk: a cache, a build directory, a checkout with a + /// large file, and a stray download. + fn fixture() -> tempfile::TempDir { + let temp = tempfile::TempDir::new().expect("tempdir"); + for (path, bytes) in [ + (".cache/pip/blob.bin", 400_000_usize), + ("src/app/target/debug/app", 300_000), + ("src/app/Cargo.toml", 100), + ("src/app/main.rs", 2_000), + ("Downloads/image.iso", 250_000), + ("notes/todo.txt", 500), + ] { + let file = temp.path().join(path); + std::fs::create_dir_all(file.parent().expect("parent")) + .expect("mkdir"); + std::fs::write(&file, vec![b'x'; bytes]).expect("write"); + } + temp + } + + /// Apparent sizes, so the numbers are the bytes written, not blocks. + #[cfg(unix)] + fn apparent() -> ScanOptions { + ScanOptions { + apparent_size: true, + ..ScanOptions::default() + } + } + + /// Scan here, as the server does when no window is open. + fn scanned(session: &mut Session, root: &Path) -> String { + let plan = session + .prepare_scan(&ScanParams { + path: Some(root.to_path_buf()), + apparent_size: true, + ..ScanParams::default() + }) + .expect("prepare"); + let ScanPlan::Local { root, options } = plan else { + panic!("no window, so the scan runs here: {plan:?}"); + }; + let scan = run_scan(root, options).expect("scan"); + session.store_scan(scan) + } + + fn unlinked(dir: &Path) -> Session { + Session::detached(dir.join("no-window.sock"), None) + } + + #[test] + fn a_scan_reports_totals_the_top_level_and_findings() { + let temp = fixture(); + let mut session = unlinked(temp.path()); + let report = scanned(&mut session, temp.path()); + assert!(report.starts_with("Scanned "), "{report}"); + assert!(report.contains("6 files"), "{report}"); + assert!(report.contains(".cache/"), "{report}"); + assert!(report.contains("Top level, largest first:"), "{report}"); + assert!(report.contains("Next: disktree_ls"), "{report}"); + assert_eq!(session.scans().len(), 1); + // Scanning again replaces, not duplicates. + scanned(&mut session, temp.path()); + assert_eq!(session.scans().len(), 1); + } + + #[test] + fn ls_needs_a_scan_and_then_lists_largest_first() { + let temp = fixture(); + let mut session = unlinked(temp.path()); + let missing = session + .ls(&LsParams { + path: temp.path().join("src"), + ..LsParams::default() + }) + .expect_err("nothing scanned"); + assert!(missing.contains("disktree_scan"), "{missing}"); + + scanned(&mut session, temp.path()); + let listing = session + .ls(&LsParams { + path: temp.path().to_path_buf(), + limit: Some(2), + show: false, + }) + .expect("ls"); + let lines: Vec<&str> = listing.lines().collect(); + // Header, then .cache (400k), src (302.1k), then the rest. + assert!(lines[1].contains(".cache/"), "{listing}"); + assert!(lines[2].contains("src/"), "{listing}"); + assert!(lines[3].contains("2 more"), "{listing}"); + + let outside = session + .ls(&LsParams { + path: PathBuf::from("/definitely/elsewhere"), + ..LsParams::default() + }) + .expect_err("outside"); + assert!(outside.contains("outside the scanned roots"), "{outside}"); + } + + #[test] + fn find_filters_by_kind_size_name_and_reclaim() { + let temp = fixture(); + let mut session = unlinked(temp.path()); + scanned(&mut session, temp.path()); + + let files = session + .find(&FindParams { + kind: Some(KindName::File), + min_bytes: Some(200_000), + ..FindParams::default() + }) + .expect("find"); + assert!(files.contains("blob.bin"), "{files}"); + assert!(files.contains("image.iso"), "{files}"); + let app_path = Path::new("debug").join("app"); + assert!(files.contains(&app_path.display().to_string()), "{files}"); + assert!(!files.contains("todo.txt"), "{files}"); + + let reclaim = session + .find(&FindParams { + reclaimable: true, + ..FindParams::default() + }) + .expect("find"); + // The topmost reclaimable directory, once, without its contents. + assert!(reclaim.contains(".cache/"), "{reclaim}"); + assert!(reclaim.contains("target/"), "{reclaim}"); + assert!(!reclaim.contains("blob.bin"), "{reclaim}"); + + let named = session + .find(&FindParams { + name: Some("ISO".into()), + ..FindParams::default() + }) + .expect("find"); + assert!(named.starts_with("1 match "), "{named}"); + + let none = session + .find(&FindParams { + name: Some("nothing-here".into()), + ..FindParams::default() + }) + .expect("find"); + assert!(none.contains("Nothing"), "{none}"); + } + + #[test] + fn marks_are_planned_with_the_guards_and_without_a_window() { + let temp = fixture(); + let mut session = unlinked(temp.path()); + scanned(&mut session, temp.path()); + + let root_refused = session + .mark(&PathsParams { + paths: vec![temp.path().to_path_buf()], + }) + .expect_err("the root alone"); + assert!(root_refused.contains("scanned root"), "{root_refused}"); + // With a good path beside it, the root is reported and the rest + // is marked. + let partial = session + .mark(&PathsParams { + paths: vec![ + temp.path().to_path_buf(), + temp.path().join("notes"), + ], + }) + .expect("one of two"); + assert!(partial.starts_with("Not marked:"), "{partial}"); + assert!(partial.contains("Marked 1"), "{partial}"); + session + .unmark(&PathsParams { paths: vec![] }) + .expect("clear"); + + let plan = session + .mark(&PathsParams { + paths: vec![ + temp.path().join(".cache"), + temp.path().join("src/app/target"), + // Inside the first mark: covered, not counted twice. + temp.path().join(".cache/pip"), + ], + }) + .expect("mark"); + assert!(plan.starts_with("Marked 3"), "{plan}"); + assert!(plan.contains("Covered by another mark (1)"), "{plan}"); + assert!(plan.contains("Frees"), "{plan}"); + assert!(plan.contains("Window: none open, and none can be opened")); + assert_eq!(session.marks().len(), 3); + + let fewer = session + .unmark(&PathsParams { + paths: vec![temp.path().join(".cache/pip")], + }) + .expect("unmark"); + assert!(fewer.starts_with("Marked 2"), "{fewer}"); + let none = session + .unmark(&PathsParams { paths: vec![] }) + .expect("unmark all"); + assert!(none.starts_with("Nothing is marked."), "{none}"); + + let review = session.review().expect_err("nothing to review"); + assert!(review.contains("Nothing is marked"), "{review}"); + } + + /// A stand-in for the window: holds a real tree of the fixture, answers + /// over the real socket the way the app does, and records what it was + /// asked. + #[cfg(unix)] + struct FakeWindow { + listener: Arc, + asked: Arc>>, + marks: Arc>>, + stop: Arc, + } + + #[cfg(unix)] + struct FakeTree { + root: PathBuf, + tree: Arc, + marks: Vec, + } + + #[cfg(unix)] + impl FakeTree { + fn state(&self, screen: &str) -> State { + State { + version: VERSION, + root: self.root.clone(), + current: self.root.clone(), + screen: screen.into(), + scanning: false, + scanned: true, + marks: self.marks.clone(), + space: None, + removal: Some(Removal { + mode: "trash".into(), + removed: 2, + failed: 0, + bytes: 700_000, + freed: Some(690_000), + at: now() - 120, + }), + tree: Some(TreeSummary { + bytes: self.tree.bytes, + files: self.tree.files, + dirs: u64::from(self.tree.dirs), + scanned_at: now() - 30, + elapsed_ms: 1_500, + unreadable: 0, + apparent_size: true, + include_hidden: true, + }), + insights: vec![Insight { + path: self.root.join(".cache"), + bytes: 400_000, + what: "reclaimable: cache".into(), + }], + } + } + + fn answer(&mut self, request: &Request) -> Response { + match request { + Request::Hello { .. } | Request::State => { + Response::State(self.state("explore")) + } + Request::Scan { root, .. } => { + assert_eq!(*root, self.root, "the fake has one tree"); + Response::State(self.state("explore")) + } + Request::Ls { path, limit } => { + let Some((_, node)) = path + .strip_prefix(&self.root) + .ok() + .and_then(|relative| self.tree.descend(relative)) + else { + return Response::error("not in the window's tree"); + }; + Response::Listing(Listing::of( + &self.root, + self.tree.bytes, + path, + node, + *limit, + )) + } + Request::Find(request) => { + let filter = match request.filter() { + Ok(filter) => filter, + Err(message) => return Response::error(message), + }; + let found = query::find( + &self.root, + &self.tree, + &filter, + request.limit, + ); + Response::Found(FoundList::of(&found)) + } + Request::Mark { targets, .. } => { + let mut refused = Vec::new(); + for target in targets { + let node = + target.path.strip_prefix(&self.root).ok().and_then( + |relative| self.tree.descend(relative), + ); + let Some((_, node)) = node else { + refused.push(Refused { + path: target.path.clone(), + reason: "not in the window's tree".into(), + }); + continue; + }; + if !self.marks.iter().any(|m| m.path == target.path) { + self.marks.push(Mark { + path: target.path.clone(), + bytes: node.bytes, + is_dir: node.is_dir(), + }); + } + } + Response::Marked { + state: self.state("explore"), + refused, + } + } + Request::Unmark { paths } if paths.is_empty() => { + self.marks.clear(); + Response::State(self.state("explore")) + } + Request::Unmark { paths } => { + self.marks.retain(|mark| !paths.contains(&mark.path)); + Response::State(self.state("explore")) + } + Request::Show { .. } | Request::Review => Response::Ok, + } + } + } + + #[cfg(unix)] + impl FakeWindow { + fn open(socket: &Path, root: &Path) -> Self { + let listener = Arc::new(Listener::bind_at(socket).expect("bind")); + let asked = Arc::new(Mutex::new(Vec::new())); + let marks: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (l, a, m, s) = ( + Arc::clone(&listener), + Arc::clone(&asked), + Arc::clone(&marks), + Arc::clone(&stop), + ); + let mut fake = FakeTree { + root: root.to_path_buf(), + tree: Arc::new( + disktree_core::scan::scan(root, apparent()).expect("scan"), + ), + marks: Vec::new(), + }; + thread::spawn(move || { + while !s.load(Ordering::Relaxed) { + for incoming in l.drain() { + if let Incoming::Request { request, reply, .. } = + incoming + { + a.lock().expect("asked").push(request.clone()); + let response = fake.answer(&request); + m.lock().expect("marks").clone_from(&fake.marks); + reply.send(response); + } + } + thread::sleep(Duration::from_millis(5)); + } + }); + Self { + listener, + asked, + marks, + stop, + } + } + + fn asked(&self) -> Vec { + self.asked.lock().expect("asked").clone() + } + + fn marks(&self) -> Vec { + self.marks.lock().expect("marks").clone() + } + } + + #[cfg(unix)] + impl Drop for FakeWindow { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = &self.listener; + } + } + + #[cfg(unix)] + #[test] + fn with_a_window_open_its_tree_is_what_the_agent_reads() { + let temp = fixture(); + let socket = temp.path().join("window.sock"); + let window = FakeWindow::open(&socket, temp.path()); + let mut session = Session::detached(socket, None); + session.set_client("claude-code"); + // No scan here at all: everything comes from the window. + assert!(session.scans().is_empty()); + + let listing = session + .ls(&LsParams { + path: temp.path().join("src"), + limit: Some(5), + show: false, + }) + .expect("ls over the link"); + assert!(listing.contains("app/"), "{listing}"); + assert!(listing.contains("% of "), "{listing}"); + + let outside = session + .ls(&LsParams { + path: PathBuf::from("/definitely/elsewhere"), + ..LsParams::default() + }) + .expect_err("outside the window"); + assert!(outside.contains("window's tree"), "{outside}"); + assert!(outside.contains("disktree_scan"), "{outside}"); + + let reclaim = session + .find(&FindParams { + reclaimable: true, + ..FindParams::default() + }) + .expect("find over the link"); + assert!(reclaim.contains(".cache/"), "{reclaim}"); + assert!(reclaim.contains("target/"), "{reclaim}"); + + // The window measures the mark; a path it does not have is refused. + let plan = session + .mark(&PathsParams { + paths: vec![ + temp.path().join(".cache"), + temp.path().join("not-there"), + ], + }) + .expect("mark"); + assert!(plan.starts_with("Not marked:"), "{plan}"); + assert!(plan.contains("not in the window's tree"), "{plan}"); + assert!(plan.contains("Marked 1"), "{plan}"); + assert_eq!(window.marks()[0].bytes, 400_000); + assert_eq!(session.marks()[0].bytes, 400_000); + + // A scan goes to the window and is read back from it. + let plan = session + .prepare_scan(&ScanParams { + path: Some(temp.path().to_path_buf()), + apparent_size: true, + ..ScanParams::default() + }) + .expect("prepare"); + let ScanPlan::Window { root } = plan else { + panic!("a linked window scans: {plan:?}"); + }; + let report = session + .window_scan(&root) + .expect("poll") + .expect("the fake finishes at once"); + assert!(report.starts_with("Scanned "), "{report}"); + assert!(report.contains("6 files"), "{report}"); + assert!(report.contains("Top level, largest first:"), "{report}"); + assert!(report.contains("reclaimable: cache"), "{report}"); + assert!(report.contains("Window: open on"), "{report}"); + assert!(session.scans().is_empty(), "nothing is scanned here"); + + let status = session.status(); + assert!(status.contains("Claude Code"), "{status}"); + assert!(status.contains("Last removal: trash"), "{status}"); + assert!(status.contains("scanned "), "{status}"); + assert!(!status.contains("Scans: none yet"), "{status}"); + + let asked = window.asked(); + assert!( + matches!(asked[0], Request::Hello { ref client, .. } if client == "Claude Code") + ); + for wanted in ["ls", "find", "mark", "scan"] { + assert!( + asked.iter().any(|request| match request { + Request::Ls { .. } => wanted == "ls", + Request::Find(_) => wanted == "find", + Request::Mark { .. } => wanted == "mark", + Request::Scan { .. } => wanted == "scan", + _ => false, + }), + "{wanted} went to the window: {asked:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn with_a_window_open_marks_live_there_and_review_opens_it() { + let temp = fixture(); + let socket = temp.path().join("window.sock"); + let window = FakeWindow::open(&socket, temp.path()); + let mut session = Session::detached(socket, None); + + let plan = session + .mark(&PathsParams { + paths: vec![temp.path().join(".cache")], + }) + .expect("mark"); + assert!(plan.starts_with("Marked 1"), "{plan}"); + assert!(plan.contains("Window: open on"), "{plan}"); + assert!(plan.contains("1 mark"), "{plan}"); + + let shown = session + .ls(&LsParams { + path: temp.path().join("src"), + limit: None, + show: true, + }) + .expect("ls with show"); + assert!(shown.contains("Window: showing"), "{shown}"); + + let review = session.review().expect("review"); + assert!(review.contains("review screen is open"), "{review}"); + + let asked = window.asked(); + assert!( + asked + .iter() + .any(|request| matches!(request, Request::Show { .. })) + ); + assert!( + asked + .iter() + .any(|request| matches!(request, Request::Review)) + ); + } + + #[cfg(unix)] + #[test] + fn marks_made_before_the_window_opened_are_handed_over() { + let temp = fixture(); + let socket = temp.path().join("window.sock"); + let mut session = Session::detached(socket.clone(), None); + scanned(&mut session, temp.path()); + session + .mark(&PathsParams { + paths: vec![temp.path().join("Downloads")], + }) + .expect("mark offline"); + assert_eq!(session.marks().len(), 1); + + let window = FakeWindow::open(&socket, temp.path()); + let review = session.review().expect("review"); + assert!(review.contains("1 marked"), "{review}"); + let asked = window.asked(); + let handed = asked.iter().find_map(|request| match request { + Request::Mark { targets, .. } => Some(targets.clone()), + _ => None, + }); + assert_eq!( + handed.map(|targets| targets[0].path.clone()), + Some(temp.path().join("Downloads")) + ); + } + + #[test] + fn client_names_read_like_products() { + let temp = fixture(); + let mut session = unlinked(temp.path()); + session.set_client("claude-code"); + assert_eq!(session.client(), "Claude Code"); + session.set_client("codex-cli"); + assert_eq!(session.client(), "Codex"); + session.set_client("claude-ai"); + assert_eq!(session.client(), "Claude"); + session.set_client("zed"); + assert_eq!(session.client(), "zed"); + session.set_client(""); + assert_eq!(session.client(), "an agent"); + } +} diff --git a/skills/disktree-free-space/SKILL.md b/skills/disktree-free-space/SKILL.md new file mode 100644 index 0000000..250eed9 --- /dev/null +++ b/skills/disktree-free-space/SKILL.md @@ -0,0 +1,78 @@ +--- +name: disktree-free-space +description: Find what is filling a disk and free space with disktree's MCP tools. Use when the user asks what is eating their disk, wants to free up space, or wants to decide what to delete. The agent scans and marks; the person removes, on disktree's review screen. +license: MIT +--- + +# Free up space with disktree + +disktree measures a tree the way the disk does: allocated blocks, hardlinks +once, hidden directories included. Your job is to turn one scan into a short +list of things the user can let go of, mark them, and hand the decision back +on the review screen. You never remove anything yourself. + +## Ground rules + +- **Mark, don't delete.** `disktree_mark` only marks. `disktree_review` + opens the review screen in the disktree window, where the user presses + Enter to move the marks to the trash, or chooses permanent deletion, which + always asks first. Do not delete marked paths with shell commands instead; + the window keeps the free-space meter honest and the user in control. +- **Prefer the owning tool for tool-owned data.** Package stores, container + images, simulators, toolchains and build caches have their own clean + commands (`cargo clean`, `pnpm store prune`, `docker system prune`, + `xcrun simctl delete unavailable`, `uv cache clean`). Mention them as the + alternative; marking the directory is still fine when the user wants the + space now. +- **Check before marking a checkout.** For a git repository or worktree, run + `git status --short`, `git stash list` and look for unpushed commits. Work + that exists nowhere else is the user's call, not yours. +- **Sizes are bytes on disk.** A sparse disk image or a VM disk shows what it + really occupies. On macOS, APFS clones (Finder duplicates, `cp -c`, some + package managers) share blocks: two copies cost one, and removing one + frees nothing. On Linux, pnpm stores and other hardlinked trees free their + space only when the last name goes. +- **Say what you cannot see.** `disktree_scan` compares the scan's total + with the volume's used space. A big gap is space files do not explain: + Time Machine local snapshots or other APFS volumes on macOS, btrfs or ZFS + snapshots, swap, deleted-but-open files. Deleting files will not fix that + part; name the likely cause and the read-only command that confirms it + (`tmutil listlocalsnapshots /`, `diskutil apfs list`, `btrfs subvolume + list`, `zfs list -t snapshot`, `lsof +L1`). + +## Procedure + +1. **Measure the gap.** `disktree_status` shows the volumes, their free + space, and whether a disktree window is open. When one is, its tree is + what every tool reads: you and the user look at the same numbers, and + a path outside its root needs a `disktree_scan` from higher up, which + the window follows. Note what the user wants: a number, or "whatever + is safe". +2. **Scan.** `disktree_scan` with no path scans the home directory in a + few seconds; `disk: true` scans the whole volume. With a window open it + does the scanning and shows the result. The result lists the top + level, largest first, and *worth a look*: the biggest directories that + could plausibly go (caches, build output, package stores, stale agent + worktrees and experiments). +3. **Walk, biggest first.** `disktree_ls` on the largest directories, a few + levels down, until a directory clearly belongs to one project or one + tool. Pass `show: true` so the window follows you; the user then sees + what you see. +4. **Sweep.** `disktree_find` with `reclaimable: true` lists every cache, + build directory and package store at once. `kind: "file"` with a + `min_bytes` finds the huge files. `older_than_days` finds what nobody + has touched. `name` matches a substring of the name. +5. **Classify each candidate**, in one line each: regenerable (caches, + build output, installed dependencies), tool-owned (stores, images, + simulators), user data (media, downloads, archives, VM disks: ask), or + hands-off (anything the OS or an app owns). +6. **Mark.** `disktree_mark` with the paths. Read the plan it returns: what + is kept back and why (the home directory, a mount point, a system tree, + a path outside the scan), what is covered by another mark, and the + projected free space, which counts only bytes on the measured volume. +7. **Hand it over.** `disktree_review` opens the review screen. Tell the + user what is marked and what it frees, and that Enter there moves it to + the trash. Afterwards `disktree_status` reports what was actually freed. + +A question about where the space went can end with the explanation. It is +not a request to delete anything.