Implement step 1 of the config redesign: pi session model
Rework the session model per docs/config-redesign.md, replacing
copy-and-clear assembly with mounts so concurrent sessions can't yank
files from under each other:

- Pi state is ephemeral by default: each session mounts a fresh empty
  writable dir at /root/.pi/agent, with the allowlisted persistent
  pieces bound on top — auth.json (global, now at
  $XDG_DATA_HOME/ramekin/agents/pi/, migrated from the old config-dir
  location) and the per-repo sessions/ dir.
- Host agent config replaces the pi {} block: the config-shaped
  entries of ~/.pi/agent/ (AGENTS.md, skills/) mount read-only at
  their normal paths, canonicalized and skipped when missing. In-
  container edits to config fail loudly instead of vanishing.
- Staples (~/.config/git, ~/.config/jj) move into the binary as the
  lowest config layer, overridable by user/project KDL; a /dev/null
  source masks (removes) an inherited mount.
- Workspaces mount at /workspace/<slug> so cwd-keyed agent state
  never collides across repos; the rendered prompt is templated with
  the workspace path and mounted read-only.
- Teardown logs anything the agent wrote to its session-scoped dir
  before discarding it, the learning loop for refining the
  persistence allowlist.

Harvested from the claude-code branch: long-form compose binds,
deterministic parent-before-child mount ordering, per-slug workspace
mounts, and a side-effect-free ramekin config (resolve computes,
prepare materializes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
change
commit 3214d92b499b1d68e4edd1b0c55e6d3dceabcab0
author Claude <noreply@anthropic.com>
date
parent c2e90c31
diff --git a/AGENTS.md b/AGENTS.md
index 4687527..624c61a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Ramekin is a containerized harness for running the [pi coding agent](https://git
 Cargo.toml              # Single-crate workspace
 src/
   main.rs               # CLI: builds image, generates compose, starts container, attaches
-  config.rs             # KDL config loading, mount resolution, pi assembly
+  config.rs             # KDL config layers, mount resolution and merging
 build.rs                # Sets RAMEKIN_VERSION from env or git rev
 assets/
   Dockerfile            # Agent container image (Node.js + pi + jj + Rust)
@@ -42,11 +42,14 @@ just           # All four
 
 ## Architecture notes
 
-- Docker compose config is generated at runtime, not a static file. The `generate_compose` function builds a YAML string from resolved paths and volume mounts.
-- XDG directories under the `ramekin` prefix store pi state: data in `$XDG_DATA_HOME/ramekin`, config in `$XDG_CONFIG_HOME/ramekin`.
-- Each workspace gets a per-repo sessions directory keyed by a `<dirname>-<hash>` slug.
+- The config redesign is documented in `docs/config-redesign.md`; the session model, layer structure, and persistence policy below implement its step 1 (pi only).
+- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), which sidesteps the colon-delimited `source:target[:ro]` format and its quoting hazards. Mounts are ordered lexicographically by target so parents precede children.
+- Config merges three layers, lowest precedence first: binary (compiled-in staples `~/.config/git`/`~/.config/jj` plus the pi agent-config allowlist `~/.pi/agent/{AGENTS.md,skills}`, mounted read-only, canonicalized, skip-if-missing), user (`~/.config/ramekin/config.kdl`), and project (`<workspace>/.ramekin/config.kdl`). A `/dev/null` source masks (removes) a mount inherited from a lower layer.
+- Pi state is ephemeral by default: each session mounts a fresh empty dir at `/root/.pi/agent`, with persistent pieces bound on top — `auth.json` from `$XDG_DATA_HOME/ramekin/agents/pi/`, per-repo `sessions/` from `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/` (slug is `<dirname>-<hash>`). On teardown, anything else the agent wrote to the session dir is logged before being discarded.
+- Each workspace mounts at `/workspace/<slug>` (never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
+- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); `Ramekin::prepare`, called from `run`, creates directories and initializes/migrates `auth.json`.
 - If the workspace contains `.ramekin/Dockerfile`, the CLI builds it on top of `ramekin-agent` instead of using the base image directly.
-- The `ramekin-prompt.md` file is written into the agent config directory on every run and passed to pi via `--append-system-prompt`. It injects container environment context into the system prompt.
+- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only into the agent dir, and passed to pi via `--append-system-prompt`.
 - Version is set at build time via the `RAMEKIN_VERSION` env var (used by CI) or falls back to `dev+<short-sha>`.
 
 ## Dependencies
diff --git a/README.md b/README.md
index dd094a4..f8db01c 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,12 @@ A Rust CLI orchestrates a Docker Compose stack. On each run it:
 
 1. Creates XDG directories for persistent state
 2. Writes the embedded Dockerfile to `$XDG_CACHE_HOME/ramekin/`
-3. Generates a compose config and writes it to a session-scoped cache directory
+3. Generates a compose config, renders the system prompt, and creates a fresh agent dir, all in a session-scoped cache directory
 4. Builds the agent image (and a project-specific layer, if one exists)
-5. Starts the agent container with the workspace mounted at `/workspace`
-6. Attaches interactively, then tears down on exit
+5. Starts the agent container with the workspace mounted at `/workspace/<slug>` (where `<slug>` is `<dirname>-<hash>`, so cwd-keyed agent state never collides across repos)
+6. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dir before discarding it
+
+Concurrent sessions don't interfere: everything a run touches is either read-only config, session-scoped plumbing under a random session id, or agent state the agent itself manages concurrently.
 
 ### Subcommands
 
@@ -36,27 +38,34 @@ ramekin completions zsh > ~/.zfunc/_ramekin
 ramekin completions bash > ~/.local/share/bash-completion/completions/ramekin
 ```
 
+### Agent config
+
+Pi's config comes from the host's own agent dir: the config-shaped entries of `~/.pi/agent/` (`AGENTS.md`, `skills/`) mount read-only at their normal paths inside the container. Ramekin keeps no parallel copy — edit the host files (or the dotfiles they symlink to) and the next session sees the changes. The rest of `~/.pi/agent/` is host runtime state (credentials, session history) and never enters the container.
+
+Config is immutable from inside the container by design: in-container edits to it fail loudly instead of silently disappearing.
+
 ### Persistence
 
-The agent directory (`$XDG_CONFIG_HOME/ramekin/agent/`) is mounted into the container at `/root/.pi/agent`. On each run, everything except `auth.json` is cleared and reassembled from pi config entries defined in KDL config files. The `ramekin-prompt.md` system prompt is always written fresh.
+Pi's container state is ephemeral by default: each session gets a fresh, empty writable dir at `/root/.pi/agent`, discarded on teardown. What persists is allowlisted and bind-mounted on top:
 
-The full pi data directory (`$XDG_DATA_HOME/ramekin/`) is mounted at `/root/.pi` for auth tokens and session history. Each workspace also gets its own sessions directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
+- `auth.json` — global, at `$XDG_DATA_HOME/ramekin/agents/pi/auth.json`, so the containerized agent keeps its own credentials (separate from the host's)
+- `sessions/` — per-repo, at `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`
+
+On teardown, ramekin logs anything else the agent wrote to its session dir before discarding it, so a path that deserves persistence gets noticed rather than silently vanishing.
 
 ### Volume mounts
 
-Additional host paths can be mounted into the container via KDL config files. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
+Mount configuration merges across three layers, lowest to highest precedence:
+
+1. **Binary** — compiled-in staples (`~/.config/git`, `~/.config/jj`, read-only, skipped when missing) and the pi agent-config mounts described above
+2. **User** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
+3. **Project** — `<workspace>/.ramekin/config.kdl`
+
+Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
 
 **User config** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
 
 ```kdl
-// Mount git and jj config (read-only by default)
-mounts {
-    source "~/.config/git"
-}
-mounts {
-    source "~/.config/jj"
-}
-
 // Mount ranger database (writable)
 mounts {
     source "~/.local/share/ranger"
@@ -79,23 +88,30 @@ Each `mounts` block supports:
 | Field | Required | Description |
 |---|---|---|
 | `source` | yes | Host path (`~` expands to home directory) |
-| `target` | no | Container path; `~` expands to the container home, a relative path resolves against the `/workspace` mount, and omitting it derives the target from the source |
+| `target` | no | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
 | `writable` | no | Allow writes (read-only by default) |
 
-For example, mounting `/dev/null` over `.envrc` masks a host `.envrc` from the agent:
+When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source *masks*: it removes a mount inherited from a lower layer (say, a staple or an agent-config entry this machine or project doesn't want), and where there's nothing to remove it stays a real bind, blanking a workspace file from the agent:
 
 ```kdl
+// Remove the inherited skills mount
+mounts {
+    source "/dev/null"
+    target "/root/.pi/agent/skills"
+}
+
+// Hide the repo's .envrc from the agent
 mounts {
     source "/dev/null"
     target ".envrc"
 }
 ```
 
-Mounts are merged across scopes. When both user and project configs define a mount with the same container target, the project mount wins. Builtin mounts (workspace, pi data, agent dir) cannot be overridden.
+Session mounts (the workspace, the agent dir plumbing, `auth.json`, `sessions/`, the rendered prompt) are forced and cannot be overridden from config.
 
 ### Container environment context
 
-A built-in system prompt (`ramekin-prompt.md`) is written into the agent dir and passed to pi via `--append-system-prompt`. It tells the agent about the container environment — the workspace mount, ephemeral filesystem, and networking. AGENTS.md remains fully available for user customization.
+A built-in system prompt (`ramekin-prompt.md`) is rendered per session, mounted read-only into the agent dir, and passed to pi via `--append-system-prompt`. It tells the agent about the container environment — the workspace mount, ephemeral filesystem, read-only config, and networking. AGENTS.md remains fully available for user customization.
 
 ### Custom Dockerfile
 
diff --git a/assets/ramekin-prompt.md b/assets/ramekin-prompt.md
index 83e824f..8c8efde 100644
--- a/assets/ramekin-prompt.md
+++ b/assets/ramekin-prompt.md
@@ -4,11 +4,13 @@ You are running inside a Docker container managed by **ramekin**.
 
 ## Workspace
 
-The project workspace is bind-mounted at `/workspace`. This is the only directory where your changes are visible to the host.
+The project workspace is bind-mounted at `{{WORKSPACE_PATH}}` (the container starts there). This is the only directory where your changes are visible to the host.
 
 ## Filesystem
 
-The container filesystem is ephemeral. Any files written outside `/workspace` will be lost when the session ends. System packages installed with `apt-get` do not persist across sessions — use a custom `.ramekin/Dockerfile` to add permanent dependencies.
+The container filesystem is ephemeral. Any files written outside `{{WORKSPACE_PATH}}` will be lost when the session ends. System packages installed with `apt-get` do not persist across sessions — use a custom `.ramekin/Dockerfile` to add permanent dependencies.
+
+Agent configuration (`AGENTS.md`, `skills/`, and the like) is mounted read-only by design; edits to it inside the container fail. If a config change seems worthwhile, tell the user about it instead.
 
 ## Networking
 
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 91e00cc..2395589 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -1,6 +1,7 @@
 # Config redesign
 
-Status: proposal (2026-07). Ramekin is single-user; this design leans on that
+Status: in progress (2026-07) — step 1 of the sequencing (session model on
+pi) is implemented. Ramekin is single-user; this design leans on that
 hard. The `claude-code` branch is a prototype to learn from, not a baseline
 to preserve.
 
diff --git a/src/config.rs b/src/config.rs
index 4dbccf0..3a85e92 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
 use std::fmt;
 use std::path::{Path, PathBuf};
 
@@ -10,17 +10,9 @@ pub struct Config {
     #[serde(default)]
     pub mounts: Vec<Mount>,
     #[serde(default)]
-    pub pi: Vec<PiEntry>,
-    #[serde(default)]
     pub env: HashMap<String, String>,
 }
 
-#[derive(Debug, PartialEq, Serialize, Deserialize)]
-pub struct PiEntry {
-    pub source: String,
-    pub target: Option<String>,
-}
-
 #[derive(Debug, PartialEq, Serialize, Deserialize)]
 pub struct Mount {
     pub source: String,
@@ -35,20 +27,20 @@ pub struct Mount {
 /// Configuration scope, ordered from lowest to highest precedence.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum Scope {
+    /// Compiled into the binary: staples and host agent-config mounts.
+    Binary,
     /// User-level `~/.config/ramekin/config.kdl`.
     User,
     /// Project-level `<workspace>/.ramekin/config.kdl`.
     Project,
-    /// Internal mounts managed by ramekin (highest precedence).
-    Builtin,
 }
 
 impl fmt::Display for Scope {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
+            Self::Binary => write!(f, "binary"),
             Self::User => write!(f, "user"),
             Self::Project => write!(f, "project"),
-            Self::Builtin => write!(f, "builtin"),
         }
     }
 }
@@ -57,10 +49,9 @@ impl fmt::Display for Scope {
 #[derive(Debug)]
 pub struct ConfigLayer {
     pub scope: Scope,
-    /// `None` for the default scope; `Some(path)` for file-backed scopes.
+    /// `None` for the binary scope; `Some(path)` for file-backed scopes.
     pub path: Option<PathBuf>,
     pub mounts: Vec<ResolvedMount>,
-    pub pi: Vec<PiEntry>,
     pub env: HashMap<String, String>,
 }
 
@@ -80,71 +71,64 @@ pub struct ScopedConfig {
     pub layers: Vec<ConfigLayer>,
 }
 
+/// Mask source: a mount whose source is `/dev/null` *removes* an inherited
+/// mount at the same target instead of binding anything. With no inherited
+/// mount to remove, it stays a real `/dev/null` bind, which blanks a file
+/// that exists in the image or workspace.
+const MASK_SOURCE: &str = "/dev/null";
+
 impl ScopedConfig {
     /// Return merged mounts from all layers, de-duplicated by container target.
     ///
-    /// Higher-precedence layers override mounts with the same target.
+    /// Higher-precedence layers override mounts with the same target, and a
+    /// `/dev/null` source masks (removes) a mount inherited from a lower
+    /// layer. Output order is lexicographic by target, which puts parent
+    /// paths before any child path (`/root/.pi/agent` precedes
+    /// `/root/.pi/agent/AGENTS.md`). Docker processes mount declarations in
+    /// order; a parent declared after its child would shadow the child. The
+    /// deterministic ordering also keeps repeat runs identical.
     pub fn merged_mounts(&self) -> Vec<ScopedValue<&ResolvedMount>> {
-        self.layers
-            .iter()
-            .flat_map(|layer| {
-                layer.mounts.iter().map(move |mount| {
+        let mut by_target: BTreeMap<&str, (ScopedValue<&ResolvedMount>, bool)> = BTreeMap::new();
+        for layer in &self.layers {
+            for mount in &layer.mounts {
+                let inherited = by_target.contains_key(mount.target.as_str());
+                by_target.insert(
+                    mount.target.as_str(),
                     (
-                        &mount.target,
                         ScopedValue {
                             scope: layer.scope,
                             value: mount,
                         },
-                    )
-                })
-            })
-            .collect::<HashMap<_, _>>()
-            .into_values()
-            .collect()
-    }
-
-    /// Merge pi entries from all layers, de-duplicated by resolved target.
-    ///
-    /// Higher-precedence layers override entries with the same target.
-    pub fn merged_pi(&self) -> Vec<ScopedValue<&PiEntry>> {
-        self.layers
-            .iter()
-            .flat_map(|layer| {
-                layer.pi.iter().map(move |entry| {
-                    (
-                        entry.resolve().target,
-                        ScopedValue {
-                            scope: layer.scope,
-                            value: entry,
-                        },
-                    )
-                })
-            })
-            .collect::<HashMap<_, _>>()
+                        inherited,
+                    ),
+                );
+            }
+        }
+        by_target
             .into_values()
+            .filter(|(sv, inherited)| !(*inherited && sv.value.source == Path::new(MASK_SOURCE)))
+            .map(|(sv, _)| sv)
             .collect()
     }
 
     /// Merge environment variables from all layers, de-duplicated by name.
     ///
     /// Higher-precedence layers override variables with the same name.
+    /// Output order is lexicographic by name for reproducibility.
     pub fn merged_env(&self) -> Vec<ScopedValue<(&str, &str)>> {
-        self.layers
-            .iter()
-            .flat_map(|layer| {
-                layer.env.iter().map(move |(name, value)| {
-                    (
-                        name.as_str(),
-                        ScopedValue {
-                            scope: layer.scope,
-                            value: (name.as_str(), value.as_str()),
-                        },
-                    )
-                })
-            })
-            .collect::<HashMap<_, _>>()
-            .into_values()
-            .collect()
+        let mut by_name: BTreeMap<&str, ScopedValue<(&str, &str)>> = BTreeMap::new();
+        for layer in &self.layers {
+            for (name, value) in &layer.env {
+                by_name.insert(
+                    name.as_str(),
+                    ScopedValue {
+                        scope: layer.scope,
+                        value: (name.as_str(), value.as_str()),
+                    },
+                );
+            }
+        }
+        by_name.into_values().collect()
     }
 }
 
@@ -156,17 +140,82 @@ pub struct ResolvedMount {
     pub writable: bool,
 }
 
+impl ResolvedMount {
+    /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
+    pub fn display_target(&self) -> String {
+        if self.writable {
+            self.target.clone()
+        } else {
+            format!("{} (ro)", self.target)
+        }
+    }
+}
+
+/// Staple mounts every machine gets: read-only, skipped when missing on the
+/// host, overridable (or maskable) by any config layer. The bar for a staple
+/// is "true on every machine".
+const STAPLES: &[&str] = &["~/.config/git", "~/.config/jj"];
+
+/// Host directory where pi keeps its agent config and state.
+const HOST_PI_AGENT_DIR: &str = "~/.pi/agent";
+
+/// The config-shaped entries of the host's pi agent dir. Only these mount
+/// into the container (read-only); the rest of the dir is runtime state —
+/// credentials, session history — which must not leak in.
+pub const PI_AGENT_CONFIG: &[&str] = &["AGENTS.md", "skills"];
+
+/// Pi's agent dir inside the container.
+pub const PI_AGENT_DIR: &str = "/root/.pi/agent";
+
+/// Mounts compiled into the binary: staples plus the host's pi agent config.
+///
+/// Sources are canonicalized because agent dirs and staples commonly symlink
+/// into dotfiles, and bind sources need real paths. Missing entries are
+/// skipped.
+fn binary_mounts() -> Vec<ResolvedMount> {
+    let staples = STAPLES
+        .iter()
+        .map(|source| ((*source).to_string(), resolve_container_target(source, "")));
+    let agent_config = PI_AGENT_CONFIG.iter().map(|entry| {
+        (
+            format!("{HOST_PI_AGENT_DIR}/{entry}"),
+            format!("{PI_AGENT_DIR}/{entry}"),
+        )
+    });
+
+    staples
+        .chain(agent_config)
+        .filter_map(|(source, target)| {
+            let expanded = PathBuf::from(shellexpand::tilde(&source).as_ref());
+            let canonical = expanded.canonicalize().ok()?;
+            Some(ResolvedMount {
+                source: canonical,
+                target,
+                writable: false,
+            })
+        })
+        .collect()
+}
+
 impl Config {
     /// Load all configuration layers for the given workspace.
     ///
+    /// `workspace_target` is the container path of the workspace mount;
+    /// relative mount targets resolve against it.
+    ///
     /// Layers are returned in precedence order (lowest first):
-    /// 1. User (`~/.config/ramekin/config.kdl`) — only if the file exists
-    /// 2. Project (`<workspace>/.ramekin/config.kdl`) — only if the file exists
-    /// 3. Builtin (internal mounts passed by the caller)
+    /// 1. Binary (staples and host agent-config mounts)
+    /// 2. User (`~/.config/ramekin/config.kdl`) — only if the file exists
+    /// 3. Project (`<workspace>/.ramekin/config.kdl`) — only if the file exists
     ///
     /// Returns an error if a config file exists but can't be parsed.
-    pub fn load(workspace: &Path, builtin_mounts: Vec<ResolvedMount>) -> Result<ScopedConfig> {
-        let mut layers = Vec::new();
+    pub fn load(workspace: &Path, workspace_target: &str) -> Result<ScopedConfig> {
+        let mut layers = vec![ConfigLayer {
+            scope: Scope::Binary,
+            path: None,
+            mounts: binary_mounts(),
+            env: HashMap::new(),
+        }];
 
         // User layer
         let xdg = xdg::BaseDirectories::with_prefix("ramekin");
@@ -181,8 +230,7 @@ impl Config {
             layers.push(ConfigLayer {
                 scope: Scope::User,
                 path: Some(user_path),
-                mounts: config.resolve_mounts(),
-                pi: config.pi,
+                mounts: config.resolve_mounts(workspace_target),
                 env: config.env,
             });
         }
@@ -196,21 +244,11 @@ impl Config {
             layers.push(ConfigLayer {
                 scope: Scope::Project,
                 path: Some(project_path),
-                mounts: config.resolve_mounts(),
-                pi: config.pi,
+                mounts: config.resolve_mounts(workspace_target),
                 env: config.env,
             });
         }
 
-        // Builtin layer (always present, highest precedence)
-        layers.push(ConfigLayer {
-            scope: Scope::Builtin,
-            path: None,
-            mounts: builtin_mounts,
-            pi: vec![],
-            env: HashMap::new(),
-        });
-
         Ok(ScopedConfig { layers })
     }
 
@@ -225,8 +263,11 @@ impl Config {
     }
 
     /// Resolve all mounts, skipping any whose source does not exist.
-    fn resolve_mounts(&self) -> Vec<ResolvedMount> {
-        self.mounts.iter().filter_map(|m| m.resolve()).collect()
+    fn resolve_mounts(&self, workspace_target: &str) -> Vec<ResolvedMount> {
+        self.mounts
+            .iter()
+            .filter_map(|m| m.resolve(workspace_target))
+            .collect()
     }
 }
 
@@ -236,15 +277,15 @@ impl Mount {
     /// Returns `None` if the source does not exist on the host. Files and
     /// devices (such as `/dev/null`) resolve like directories — Docker binds
     /// them all the same way.
-    pub fn resolve(&self) -> Option<ResolvedMount> {
+    pub fn resolve(&self, workspace_target: &str) -> Option<ResolvedMount> {
         let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
         if !expanded.exists() {
             return None;
         }
 
         let target = match &self.target {
-            Some(t) => resolve_container_target(t),
-            None => resolve_container_target(&self.source),
+            Some(t) => resolve_container_target(t, workspace_target),
+            None => resolve_container_target(&self.source, workspace_target),
         };
 
         Some(ResolvedMount {
@@ -255,127 +296,16 @@ impl Mount {
     }
 }
 
-/// A pi entry with its source path expanded and target resolved.
-#[derive(Debug)]
-pub struct ResolvedPiEntry {
-    pub source: PathBuf,
-    /// Target path relative to the agent dir.
-    pub target: String,
-}
-
-impl PiEntry {
-    /// Expand tildes and resolve the target.
-    ///
-    /// Uses the explicit target when set, otherwise falls back to
-    /// the source's basename.
-    pub fn resolve(&self) -> ResolvedPiEntry {
-        let source = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
-        let target = self.target.clone().unwrap_or_else(|| {
-            source
-                .file_name()
-                .map(|n| n.to_string_lossy().into_owned())
-                .unwrap_or_default()
-        });
-        ResolvedPiEntry { source, target }
-    }
-}
-
-/// Clear the agent directory, preserving only `auth.json`.
-pub fn clear_agent_dir(agent_dir: &Path) -> Result<()> {
-    if !agent_dir.exists() {
-        return Ok(());
-    }
-    for entry in fs_err::read_dir(agent_dir).into_diagnostic()? {
-        let entry = entry.into_diagnostic()?;
-        if entry.file_name() == "auth.json" {
-            continue;
-        }
-        if entry.file_type().into_diagnostic()?.is_dir() {
-            fs_err::remove_dir_all(entry.path()).into_diagnostic()?;
-        } else {
-            fs_err::remove_file(entry.path()).into_diagnostic()?;
-        }
-    }
-    Ok(())
-}
-
-/// Copy pi config entries into the agent directory.
-///
-/// Auto-detects file vs directory from the source. Warns and skips
-/// sources that don't exist on the host.
-pub fn assemble_pi(agent_dir: &Path, entries: &[ResolvedPiEntry]) -> Result<()> {
-    for entry in entries {
-        if !entry.source.exists() {
-            tracing::warn!(
-                source = %entry.source.display(),
-                target = %entry.target,
-                "pi source does not exist, skipping"
-            );
-            continue;
-        }
-
-        let target = agent_dir.join(&entry.target);
-
-        if entry.source.is_dir() {
-            copy_dir(&entry.source, &target)?;
-        } else {
-            fs_err::copy(&entry.source, &target).into_diagnostic()?;
-        }
-    }
-    Ok(())
-}
-
-/// Recursively copy a directory tree.
-fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
-    fs_err::create_dir_all(dst).into_diagnostic()?;
-    for entry in fs_err::read_dir(src).into_diagnostic()? {
-        let entry = entry.into_diagnostic()?;
-        let file_type = entry.file_type().into_diagnostic()?;
-        let target = dst.join(entry.file_name());
-        if file_type.is_dir() {
-            copy_dir(&entry.path(), &target)?;
-        } else {
-            fs_err::copy(entry.path(), &target).into_diagnostic()?;
-        }
-    }
-    Ok(())
-}
-
-impl ResolvedMount {
-    /// Format as a Docker volume mount string (`source:target` or `source:target:ro`).
-    pub fn to_volume_string(&self) -> String {
-        if self.writable {
-            format!("{}:{}", self.source.display(), self.target)
-        } else {
-            format!("{}:{}:ro", self.source.display(), self.target)
-        }
-    }
-
-    /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
-    pub fn display_target(&self) -> String {
-        if self.writable {
-            self.target.clone()
-        } else {
-            format!("{} (ro)", self.target)
-        }
-    }
-}
-
 /// Home directory inside the agent container. The ramekin Dockerfile runs
 /// everything as root, so `~` in container target paths maps here. If the
 /// image ever switches to a non-root user, update this constant.
 const CONTAINER_HOME: &str = "/root";
 
-/// Mount point for the workspace inside the agent container. Relative mount
-/// targets are resolved against this so a bare `.envrc` lands next to the
-/// project files. Keep in sync with the workspace builtin mount in `main.rs`.
-pub const CONTAINER_WORKSPACE: &str = "/workspace";
-
 /// Resolve a configured target into an absolute container path.
 ///
 /// A leading `~` expands to the container home directory. A relative path is
 /// resolved against the workspace mount. Absolute paths pass through unchanged.
-fn resolve_container_target(path: &str) -> String {
+fn resolve_container_target(path: &str, workspace_target: &str) -> String {
     if let Some(rest) = path.strip_prefix("~/") {
         format!("{CONTAINER_HOME}/{rest}")
     } else if path == "~" {
@@ -383,7 +313,7 @@ fn resolve_container_target(path: &str) -> String {
     } else if path.starts_with('/') {
         path.to_string()
     } else {
-        format!("{CONTAINER_WORKSPACE}/{path}")
+        format!("{workspace_target}/{path}")
     }
 }
 
@@ -391,6 +321,8 @@ fn resolve_container_target(path: &str) -> String {
 mod tests {
     use super::*;
 
+    const WS: &str = "/workspace/test-slug";
+
     #[test]
     fn kdl_deserialization_works() {
         let kdl_content = r#"
@@ -398,7 +330,7 @@ mod tests {
             source "~/.config/git"
             }
             mounts{
-            source "~/.config/jj"  
+            source "~/.config/jj"
             }
             mounts{
             source "~/.local/share/ranger"
@@ -445,27 +377,42 @@ mod tests {
         assert!(result.is_err());
     }
 
+    #[test]
+    fn kdl_dash_notation_mounts_writable() {
+        let kdl = r#"
+            mounts {
+                - { source "~/.local/share/ranger"; writable }
+            }
+        "#;
+        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
+        assert_eq!(parsed.mounts.len(), 1);
+        assert!(parsed.mounts[0].writable);
+    }
+
     #[test]
     fn resolve_container_target_with_subpath() {
         assert_eq!(
-            resolve_container_target("~/.config/git"),
+            resolve_container_target("~/.config/git", WS),
             "/root/.config/git"
         );
     }
 
     #[test]
     fn resolve_container_target_bare() {
-        assert_eq!(resolve_container_target("~"), "/root");
+        assert_eq!(resolve_container_target("~", WS), "/root");
     }
 
     #[test]
     fn resolve_container_target_absolute_unchanged() {
-        assert_eq!(resolve_container_target("/some/path"), "/some/path");
+        assert_eq!(resolve_container_target("/some/path", WS), "/some/path");
     }
 
     #[test]
     fn resolve_container_target_relative_uses_workspace() {
-        assert_eq!(resolve_container_target(".envrc"), "/workspace/.envrc");
+        assert_eq!(
+            resolve_container_target(".envrc", WS),
+            "/workspace/test-slug/.envrc"
+        );
     }
 
     #[test]
@@ -475,7 +422,7 @@ mod tests {
             target: None,
             writable: false,
         };
-        assert!(mount.resolve().is_none());
+        assert!(mount.resolve(WS).is_none());
     }
 
     #[test]
@@ -485,9 +432,9 @@ mod tests {
             target: Some(".envrc".into()),
             writable: false,
         };
-        let resolved = mount.resolve().unwrap();
+        let resolved = mount.resolve(WS).unwrap();
         assert_eq!(resolved.source, PathBuf::from("/dev/null"));
-        assert_eq!(resolved.target, "/workspace/.envrc");
+        assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
     }
 
     #[test]
@@ -497,7 +444,7 @@ mod tests {
             target: Some("/container/tmp".into()),
             writable: false,
         };
-        let resolved = mount.resolve().unwrap();
+        let resolved = mount.resolve(WS).unwrap();
         assert_eq!(resolved.source, PathBuf::from("/tmp"));
         assert_eq!(resolved.target, "/container/tmp");
         assert!(!resolved.writable);
@@ -510,7 +457,7 @@ mod tests {
             target: Some("~/downloads".into()),
             writable: true,
         };
-        let resolved = mount.resolve().unwrap();
+        let resolved = mount.resolve(WS).unwrap();
         assert_eq!(resolved.target, "/root/downloads");
     }
 
@@ -521,56 +468,10 @@ mod tests {
             target: None,
             writable: true,
         };
-        let resolved = mount.resolve().unwrap();
+        let resolved = mount.resolve(WS).unwrap();
         assert_eq!(resolved.target, "/tmp");
     }
 
-    #[test]
-    fn volume_string_read_only() {
-        let m = ResolvedMount {
-            source: PathBuf::from("/home/user/.config/git"),
-            target: "/root/.config/git".into(),
-            writable: false,
-        };
-        assert_eq!(
-            m.to_volume_string(),
-            "/home/user/.config/git:/root/.config/git:ro"
-        );
-    }
-
-    #[test]
-    fn volume_string_read_write() {
-        let m = ResolvedMount {
-            source: PathBuf::from("/home/user/.local/share/ranger"),
-            target: "/root/.local/share/ranger".into(),
-            writable: true,
-        };
-        assert_eq!(
-            m.to_volume_string(),
-            "/home/user/.local/share/ranger:/root/.local/share/ranger"
-        );
-    }
-
-    #[test]
-    fn display_target_read_only() {
-        let m = ResolvedMount {
-            source: PathBuf::from("/x"),
-            target: "/root/.config/git".into(),
-            writable: false,
-        };
-        assert_eq!(m.display_target(), "/root/.config/git (ro)");
-    }
-
-    #[test]
-    fn display_target_read_write() {
-        let m = ResolvedMount {
-            source: PathBuf::from("/x"),
-            target: "/root/.local/share/ranger".into(),
-            writable: true,
-        };
-        assert_eq!(m.display_target(), "/root/.local/share/ranger");
-    }
-
     #[test]
     fn resolve_mounts_filters_nonexistent() {
         let config = Config {
@@ -586,160 +487,72 @@ mod tests {
                     writable: false,
                 },
             ],
-            pi: vec![],
             env: HashMap::new(),
         };
-        let resolved = config.resolve_mounts();
+        let resolved = config.resolve_mounts(WS);
         assert_eq!(resolved.len(), 1);
         assert_eq!(resolved[0].target, "/container/tmp");
     }
 
     #[test]
     fn scope_display() {
+        assert_eq!(Scope::Binary.to_string(), "binary");
         assert_eq!(Scope::User.to_string(), "user");
         assert_eq!(Scope::Project.to_string(), "project");
-        assert_eq!(Scope::Builtin.to_string(), "builtin");
     }
 
-    #[test]
-    fn merged_mounts_accumulates_across_layers() {
-        let config = ScopedConfig {
-            layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: Some(PathBuf::from("/user/config.kdl")),
-                    mounts: vec![ResolvedMount {
-                        source: PathBuf::from("/a"),
-                        target: "/a".into(),
-                        writable: false,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: Some(PathBuf::from("/project/config.kdl")),
-                    mounts: vec![ResolvedMount {
-                        source: PathBuf::from("/b"),
-                        target: "/b".into(),
-                        writable: true,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-            ],
-        };
-        let merged = config.merged_mounts();
-        assert_eq!(merged.len(), 2);
-        let a = merged.iter().find(|sv| sv.value.target == "/a").unwrap();
-        assert_eq!(a.scope, Scope::User);
-        let b = merged.iter().find(|sv| sv.value.target == "/b").unwrap();
-        assert_eq!(b.scope, Scope::Project);
+    fn layer(scope: Scope, mounts: Vec<ResolvedMount>) -> ConfigLayer {
+        ConfigLayer {
+            scope,
+            path: None,
+            mounts,
+            env: HashMap::new(),
+        }
     }
 
-    #[test]
-    fn merged_mounts_single_layer() {
-        let config = ScopedConfig {
-            layers: vec![ConfigLayer {
-                scope: Scope::User,
-                path: Some(PathBuf::from("/user/config.kdl")),
-                mounts: vec![ResolvedMount {
-                    source: PathBuf::from("/a"),
-                    target: "/a".into(),
-                    writable: false,
-                }],
-                pi: vec![],
-                env: HashMap::new(),
-            }],
-        };
-        let merged = config.merged_mounts();
-        assert_eq!(merged.len(), 1);
-        assert_eq!(merged[0].value.target, "/a");
+    fn mount(source: &str, target: &str, writable: bool) -> ResolvedMount {
+        ResolvedMount {
+            source: PathBuf::from(source),
+            target: target.into(),
+            writable,
+        }
     }
 
     #[test]
-    fn merged_mounts_all_three_layers() {
+    fn merged_mounts_accumulates_across_layers() {
         let config = ScopedConfig {
             layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: Some(PathBuf::from("/user/config.kdl")),
-                    mounts: vec![ResolvedMount {
-                        source: PathBuf::from("/a"),
-                        target: "/a".into(),
-                        writable: false,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: Some(PathBuf::from("/project/config.kdl")),
-                    mounts: vec![ResolvedMount {
-                        source: PathBuf::from("/b"),
-                        target: "/b".into(),
-                        writable: true,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Builtin,
-                    path: None,
-                    mounts: vec![ResolvedMount {
-                        source: PathBuf::from("/c"),
-                        target: "/c".into(),
-                        writable: true,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
+                layer(Scope::Binary, vec![mount("/a", "/a", false)]),
+                layer(Scope::User, vec![mount("/b", "/b", true)]),
+                layer(Scope::Project, vec![mount("/c", "/c", true)]),
             ],
         };
         let merged = config.merged_mounts();
         assert_eq!(merged.len(), 3);
         let a = merged.iter().find(|sv| sv.value.target == "/a").unwrap();
-        assert_eq!(a.scope, Scope::User);
+        assert_eq!(a.scope, Scope::Binary);
         let b = merged.iter().find(|sv| sv.value.target == "/b").unwrap();
-        assert_eq!(b.scope, Scope::Project);
+        assert_eq!(b.scope, Scope::User);
         let c = merged.iter().find(|sv| sv.value.target == "/c").unwrap();
-        assert_eq!(c.scope, Scope::Builtin);
+        assert_eq!(c.scope, Scope::Project);
     }
 
     #[test]
     fn merged_mounts_deduplicates_by_target() {
         let config = ScopedConfig {
             layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: Some(PathBuf::from("/user/config.kdl")),
-                    mounts: vec![
-                        ResolvedMount {
-                            source: PathBuf::from("/user/git"),
-                            target: "/root/.config/git".into(),
-                            writable: false,
-                        },
-                        ResolvedMount {
-                            source: PathBuf::from("/user/jj"),
-                            target: "/root/.config/jj".into(),
-                            writable: false,
-                        },
+                layer(
+                    Scope::User,
+                    vec![
+                        mount("/user/git", "/root/.config/git", false),
+                        mount("/user/jj", "/root/.config/jj", false),
                     ],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: Some(PathBuf::from("/project/config.kdl")),
-                    mounts: vec![ResolvedMount {
-                        // Override the user git mount with a different source
-                        source: PathBuf::from("/project/git"),
-                        target: "/root/.config/git".into(),
-                        writable: true,
-                    }],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
+                ),
+                layer(
+                    Scope::Project,
+                    // Override the user git mount with a different source
+                    vec![mount("/project/git", "/root/.config/git", true)],
+                ),
             ],
         };
         let merged = config.merged_mounts();
@@ -759,12 +572,80 @@ mod tests {
         assert!(git.value.writable);
     }
 
+    #[test]
+    fn merged_mounts_orders_parents_before_children() {
+        let config = ScopedConfig {
+            layers: vec![
+                layer(
+                    Scope::Binary,
+                    vec![mount("/host/agents-md", "/root/.pi/agent/AGENTS.md", false)],
+                ),
+                layer(
+                    Scope::User,
+                    vec![mount("/host/agent-dir", "/root/.pi/agent", true)],
+                ),
+            ],
+        };
+        let merged = config.merged_mounts();
+        let targets: Vec<&str> = merged.iter().map(|sv| sv.value.target.as_str()).collect();
+        assert_eq!(
+            targets,
+            vec!["/root/.pi/agent", "/root/.pi/agent/AGENTS.md"]
+        );
+    }
+
+    #[test]
+    fn dev_null_mask_removes_inherited_mount() {
+        let config = ScopedConfig {
+            layers: vec![
+                layer(
+                    Scope::Binary,
+                    vec![mount("/host/skills", "/root/.pi/agent/skills", false)],
+                ),
+                layer(
+                    Scope::Project,
+                    vec![mount("/dev/null", "/root/.pi/agent/skills", false)],
+                ),
+            ],
+        };
+        let merged = config.merged_mounts();
+        assert!(merged.is_empty(), "mask should remove the inherited mount");
+    }
+
+    #[test]
+    fn dev_null_without_inherited_mount_stays_a_bind() {
+        let config = ScopedConfig {
+            layers: vec![layer(
+                Scope::Project,
+                vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+            )],
+        };
+        let merged = config.merged_mounts();
+        assert_eq!(merged.len(), 1);
+        assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+    }
+
+    #[test]
+    fn mount_overriding_mask_survives() {
+        let config = ScopedConfig {
+            layers: vec![
+                layer(Scope::Binary, vec![mount("/host/skills", "/s", false)]),
+                layer(Scope::User, vec![mount("/dev/null", "/s", false)]),
+                layer(Scope::Project, vec![mount("/project/skills", "/s", false)]),
+            ],
+        };
+        let merged = config.merged_mounts();
+        assert_eq!(merged.len(), 1);
+        assert_eq!(merged[0].value.source, PathBuf::from("/project/skills"));
+        assert_eq!(merged[0].scope, Scope::Project);
+    }
+
     #[test]
     fn load_with_no_project_config() {
         // Use a workspace with no .ramekin/config.kdl
-        let config = Config::load(Path::new("/tmp"), vec![]).unwrap();
-        // Builtin layer is always last
-        assert_eq!(config.layers.last().unwrap().scope, Scope::Builtin);
+        let config = Config::load(Path::new("/tmp"), WS).unwrap();
+        // Binary layer is always first (lowest precedence)
+        assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
         // No project layer
         assert!(!config.layers.iter().any(|l| l.scope == Scope::Project));
     }
@@ -782,9 +663,9 @@ mod tests {
         )
         .unwrap();
 
-        let config = Config::load(dir.path(), vec![]).unwrap();
+        let config = Config::load(dir.path(), WS).unwrap();
 
-        // Should have project + builtin layers
+        // Should have binary + project layers
         assert!(config.layers.len() >= 2);
         let project_layer = config
             .layers
@@ -793,381 +674,8 @@ mod tests {
             .expect("project layer missing");
         assert_eq!(project_layer.mounts.len(), 2);
         assert_eq!(project_layer.mounts[0].target, "/container/tmp");
-        // Builtin is always last (highest precedence)
-        assert_eq!(config.layers.last().unwrap().scope, Scope::Builtin);
-    }
-
-    #[test]
-    fn kdl_pi_entries() {
-        let kdl = r#"
-            pi {
-                source "~/.dotfiles/ai/AGENTS.md"
-            }
-            pi {
-                source "~/.dotfiles/ai/skills"
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert!(parsed.mounts.is_empty());
-        assert_eq!(parsed.pi.len(), 2);
-        assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/AGENTS.md");
-        assert_eq!(parsed.pi[0].target, None);
-        assert_eq!(parsed.pi[1].source, "~/.dotfiles/ai/skills");
-    }
-
-    #[test]
-    fn kdl_pi_with_explicit_target() {
-        let kdl = r#"
-            pi {
-                source "~/.dotfiles/ai/my-project-skills"
-                target "skills"
-            }
-            pi {
-                source "~/.dotfiles/ai/AGENTS.md"
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert_eq!(parsed.pi.len(), 2);
-        assert_eq!(parsed.pi[0].target, Some("skills".into()));
-        assert_eq!(parsed.pi[1].target, None);
-    }
-
-    #[test]
-    fn kdl_dash_notation_pi() {
-        let kdl = r#"
-            pi {
-                - { source "~/.dotfiles/ai/_AGENTS.md"; target "AGENTS.md" }
-                - { source "~/.dotfiles/ai/skills" }
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert_eq!(parsed.pi.len(), 2);
-        assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/_AGENTS.md");
-        assert_eq!(parsed.pi[0].target, Some("AGENTS.md".into()));
-        assert_eq!(parsed.pi[1].source, "~/.dotfiles/ai/skills");
-        assert_eq!(parsed.pi[1].target, None);
-    }
-
-    #[test]
-    fn kdl_dash_notation_mounts_writable() {
-        let kdl = r#"
-            mounts {
-                - { source "~/.local/share/ranger"; writable }
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert_eq!(parsed.mounts.len(), 1);
-        assert!(parsed.mounts[0].writable);
-    }
-
-    #[test]
-    fn kdl_no_pi_section() {
-        let kdl = r#"
-            mounts {
-                source "/tmp"
-                target "/container/tmp"
-            }
-            mounts {
-                source "/tmp"
-                target "/container/tmp2"
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert!(parsed.pi.is_empty());
-        assert_eq!(parsed.mounts.len(), 2);
-    }
-
-    #[test]
-    fn kdl_pi_and_mounts_together() {
-        let kdl = r#"
-            mounts {
-                source "~/.config/git"
-            }
-            mounts {
-                source "~/.config/jj"
-            }
-            pi {
-                source "~/.dotfiles/ai/AGENTS.md"
-            }
-        "#;
-        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
-        assert_eq!(parsed.mounts.len(), 2);
-        assert_eq!(parsed.pi.len(), 1);
-        assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/AGENTS.md");
-    }
-
-    #[test]
-    fn clear_agent_dir_preserves_auth_json() {
-        let agent_dir = tempfile::tempdir().unwrap();
-
-        fs_err::write(agent_dir.path().join("auth.json"), "secret").unwrap();
-        fs_err::write(agent_dir.path().join("AGENTS.md"), "old").unwrap();
-        fs_err::write(agent_dir.path().join("settings.json"), "{}").unwrap();
-        fs_err::create_dir_all(agent_dir.path().join("skills")).unwrap();
-        fs_err::write(agent_dir.path().join("skills/x.md"), "x").unwrap();
-
-        clear_agent_dir(agent_dir.path()).unwrap();
-
-        assert!(agent_dir.path().join("auth.json").exists());
-        assert_eq!(
-            fs_err::read_to_string(agent_dir.path().join("auth.json")).unwrap(),
-            "secret"
-        );
-        assert!(!agent_dir.path().join("AGENTS.md").exists());
-        assert!(!agent_dir.path().join("settings.json").exists());
-        assert!(!agent_dir.path().join("skills").exists());
-    }
-
-    #[test]
-    fn clear_agent_dir_handles_empty_dir() {
-        let agent_dir = tempfile::tempdir().unwrap();
-        clear_agent_dir(agent_dir.path()).unwrap();
-    }
-
-    #[test]
-    fn clear_agent_dir_handles_nonexistent_dir() {
-        clear_agent_dir(Path::new("/nonexistent/path")).unwrap();
-    }
-
-    #[test]
-    fn assemble_pi_copies_file() {
-        let src_dir = tempfile::tempdir().unwrap();
-        let agent_dir = tempfile::tempdir().unwrap();
-
-        let prompt = src_dir.path().join("AGENTS.md");
-        fs_err::write(&prompt, "# my prompt").unwrap();
-
-        let entries = vec![ResolvedPiEntry {
-            source: prompt,
-            target: "AGENTS.md".into(),
-        }];
-
-        assemble_pi(agent_dir.path(), &entries).unwrap();
-
-        assert_eq!(
-            fs_err::read_to_string(agent_dir.path().join("AGENTS.md")).unwrap(),
-            "# my prompt"
-        );
-    }
-
-    #[test]
-    fn assemble_pi_copies_directory() {
-        let src_dir = tempfile::tempdir().unwrap();
-        let agent_dir = tempfile::tempdir().unwrap();
-
-        let skills = src_dir.path().join("skills");
-        fs_err::create_dir_all(skills.join("my-skill")).unwrap();
-        fs_err::write(skills.join("my-skill/SKILL.md"), "# skill").unwrap();
-
-        let entries = vec![ResolvedPiEntry {
-            source: skills,
-            target: "skills".into(),
-        }];
-
-        assemble_pi(agent_dir.path(), &entries).unwrap();
-
-        assert_eq!(
-            fs_err::read_to_string(agent_dir.path().join("skills/my-skill/SKILL.md")).unwrap(),
-            "# skill"
-        );
-    }
-
-    #[test]
-    fn assemble_pi_skips_missing_source() {
-        let agent_dir = tempfile::tempdir().unwrap();
-
-        let entries = vec![ResolvedPiEntry {
-            source: PathBuf::from("/nonexistent/file"),
-            target: "file".into(),
-        }];
-
-        assemble_pi(agent_dir.path(), &entries).unwrap();
-        assert!(!agent_dir.path().join("file").exists());
-    }
-
-    #[test]
-    fn pi_resolve_defaults_target_to_basename() {
-        let entry = PiEntry {
-            source: "~/.dotfiles/ai/AGENTS.md".into(),
-            target: None,
-        };
-        let resolved = entry.resolve();
-        assert_eq!(resolved.target, "AGENTS.md");
-    }
-
-    #[test]
-    fn pi_resolve_defaults_target_to_directory_basename() {
-        let entry = PiEntry {
-            source: "~/.dotfiles/ai/skills".into(),
-            target: None,
-        };
-        let resolved = entry.resolve();
-        assert_eq!(resolved.target, "skills");
-    }
-
-    #[test]
-    fn pi_resolve_uses_explicit_target() {
-        let entry = PiEntry {
-            source: "~/.dotfiles/ai/my-project-skills".into(),
-            target: Some("skills".into()),
-        };
-        let resolved = entry.resolve();
-        assert_eq!(resolved.target, "skills");
-    }
-
-    #[test]
-    fn clear_then_assemble_full_cycle() {
-        let src_dir = tempfile::tempdir().unwrap();
-        let agent_dir = tempfile::tempdir().unwrap();
-
-        // Simulate previous run state.
-        fs_err::write(agent_dir.path().join("auth.json"), "secret").unwrap();
-        fs_err::write(agent_dir.path().join("AGENTS.md"), "old prompt").unwrap();
-        fs_err::create_dir_all(agent_dir.path().join("old-skills")).unwrap();
-
-        // New config only has a prompt.
-        let prompt = src_dir.path().join("AGENTS.md");
-        fs_err::write(&prompt, "new prompt").unwrap();
-
-        clear_agent_dir(agent_dir.path()).unwrap();
-
-        let entries = vec![ResolvedPiEntry {
-            source: prompt,
-            target: "AGENTS.md".into(),
-        }];
-        assemble_pi(agent_dir.path(), &entries).unwrap();
-
-        // auth.json preserved.
-        assert_eq!(
-            fs_err::read_to_string(agent_dir.path().join("auth.json")).unwrap(),
-            "secret"
-        );
-        // New prompt copied.
-        assert_eq!(
-            fs_err::read_to_string(agent_dir.path().join("AGENTS.md")).unwrap(),
-            "new prompt"
-        );
-        // Old stale dir gone.
-        assert!(!agent_dir.path().join("old-skills").exists());
-    }
-
-    #[test]
-    fn merged_pi_accumulates() {
-        let config = ScopedConfig {
-            layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "~/.dotfiles/AGENTS.md".into(),
-                        target: None,
-                    }],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "/project/skills".into(),
-                        target: None,
-                    }],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Builtin,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![],
-                    env: HashMap::new(),
-                },
-            ],
-        };
-        let merged = config.merged_pi();
-        assert_eq!(merged.len(), 2);
-        let agents = merged
-            .iter()
-            .find(|sv| sv.value.source == "~/.dotfiles/AGENTS.md")
-            .unwrap();
-        assert_eq!(agents.scope, Scope::User);
-        let skills = merged
-            .iter()
-            .find(|sv| sv.value.source == "/project/skills")
-            .unwrap();
-        assert_eq!(skills.scope, Scope::Project);
-    }
-
-    #[test]
-    fn merged_pi_deduplicates_by_resolved_target() {
-        let config = ScopedConfig {
-            layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "~/.dotfiles/AGENTS.md".into(),
-                        target: None,
-                    }],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "/project/AGENTS.md".into(),
-                        target: None,
-                    }],
-                    env: HashMap::new(),
-                },
-            ],
-        };
-        let merged = config.merged_pi();
-        assert_eq!(merged.len(), 1);
-        // Project wins.
-        let entry = merged
-            .iter()
-            .find(|sv| sv.value.source == "/project/AGENTS.md")
-            .unwrap();
-        assert_eq!(entry.scope, Scope::Project);
-    }
-
-    #[test]
-    fn merged_pi_explicit_target_deduplicates_with_basename() {
-        let config = ScopedConfig {
-            layers: vec![
-                ConfigLayer {
-                    scope: Scope::User,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "~/.dotfiles/ai/skills".into(),
-                        target: None,
-                    }],
-                    env: HashMap::new(),
-                },
-                ConfigLayer {
-                    scope: Scope::Project,
-                    path: None,
-                    mounts: vec![],
-                    pi: vec![PiEntry {
-                        source: "/project/my-custom-skills".into(),
-                        target: Some("skills".into()),
-                    }],
-                    env: HashMap::new(),
-                },
-            ],
-        };
-        let merged = config.merged_pi();
-        assert_eq!(merged.len(), 1);
-        // Project wins — explicit target "skills" matches user's basename "skills".
-        let entry = merged
-            .iter()
-            .find(|sv| sv.value.source == "/project/my-custom-skills")
-            .unwrap();
-        assert_eq!(entry.scope, Scope::Project);
+        // Binary is always first (lowest precedence)
+        assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
     }
 
     #[test]
@@ -1208,14 +716,12 @@ mod tests {
                     scope: Scope::User,
                     path: None,
                     mounts: vec![],
-                    pi: vec![],
                     env: user_env,
                 },
                 ConfigLayer {
                     scope: Scope::Project,
                     path: None,
                     mounts: vec![],
-                    pi: vec![],
                     env: project_env,
                 },
             ],
diff --git a/src/main.rs b/src/main.rs
index d302ab4..9f94d21 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,14 @@
 mod config;
 
+use std::collections::{BTreeMap, BTreeSet};
 use std::path::{Path, PathBuf};
 use std::process::{Command, Stdio};
 
 use clap::{CommandFactory, Parser, Subcommand};
 use clap_complete::Shell;
-use miette::{Context, IntoDiagnostic, Result, bail};
+use miette::{Context, IntoDiagnostic, Result, bail, miette};
 use serde::Serialize;
-use tracing::{error, info};
+use tracing::{error, info, warn};
 use tracing_subscriber::{EnvFilter, fmt, prelude::*};
 
 const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
@@ -85,9 +86,16 @@ fn main() -> Result<()> {
 
 struct Ramekin {
     workspace: PathBuf,
+    /// Container path of the workspace mount: `/workspace/<slug>`. Per-repo
+    /// so anything the agent keys by cwd (pi's session grouping) gets a
+    /// distinct path per repo instead of every repo looking like the same
+    /// `/workspace` project.
+    workspace_target: String,
     xdg: xdg::BaseDirectories,
-    agent_dir: PathBuf,
-    pi_data_dir: PathBuf,
+    /// Persistent pi state: `$XDG_DATA_HOME/ramekin/agents/pi/`. Holds
+    /// `auth.json`, the one global file that survives across sessions.
+    pi_state_dir: PathBuf,
+    /// Per-repo session history: `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
     repo_sessions_dir: PathBuf,
     cache_dir: PathBuf,
     custom_dockerfile: Option<PathBuf>,
@@ -95,8 +103,9 @@ struct Ramekin {
 }
 
 impl Ramekin {
-    /// Resolve all paths, create XDG directories, assemble pi config, and
-    /// resolve mounts.
+    /// Resolve all paths and load config layers. Side-effect free: nothing
+    /// is created or written until `run` calls `prepare`, so `ramekin
+    /// config` can inspect state without mutating it.
     fn resolve(workspace_arg: PathBuf) -> Result<Self> {
         let workspace = workspace_arg
             .canonicalize()
@@ -106,72 +115,31 @@ impl Ramekin {
             })?;
 
         let xdg = xdg::BaseDirectories::with_prefix("ramekin");
-
-        // Agent directory mirrors /root/.pi/agent in the container.
-        let agent_dir = xdg
-            .create_config_directory("agent")
-            .into_diagnostic()
-            .wrap_err("failed to create agent config directory")?;
-
-        let pi_data_dir = xdg
-            .create_data_directory("")
-            .into_diagnostic()
-            .wrap_err("failed to create pi data directory")?;
+        let data_home = xdg
+            .get_data_home()
+            .ok_or_else(|| miette!("could not determine XDG data home"))?;
+        let cache_dir = xdg
+            .get_cache_home()
+            .ok_or_else(|| miette!("could not determine XDG cache home"))?;
 
         let repo_slug = repo_slug(&workspace);
-        let repo_sessions_dir = xdg
-            .create_data_directory(format!("repos/{repo_slug}/sessions"))
-            .into_diagnostic()
-            .wrap_err("failed to create repo sessions directory")?;
-
-        let cache_dir = xdg
-            .create_cache_directory("")
-            .into_diagnostic()
-            .wrap_err("failed to create cache directory")?;
+        let workspace_target = format!("/workspace/{repo_slug}");
+        let pi_state_dir = data_home.join("agents/pi");
+        let repo_sessions_dir = data_home.join(format!("repos/{repo_slug}/sessions"));
 
         let custom_dockerfile_path = workspace.join(".ramekin/Dockerfile");
         let custom_dockerfile = custom_dockerfile_path
             .is_file()
             .then_some(custom_dockerfile_path);
 
-        // Builtin mounts (always present, not overridable)
-        let builtin_entries = [
-            (&pi_data_dir, "/root/.pi"),
-            (&agent_dir, "/root/.pi/agent"),
-            (&repo_sessions_dir, "/root/.pi/agent/sessions"),
-            (&workspace, config::CONTAINER_WORKSPACE),
-        ];
-        let builtin_mounts: Vec<config::ResolvedMount> = builtin_entries
-            .into_iter()
-            .map(|(source, target)| config::ResolvedMount {
-                source: source.clone(),
-                target: target.into(),
-                writable: true,
-            })
-            .collect();
-
-        let config = config::Config::load(&workspace, builtin_mounts)
+        let config = config::Config::load(&workspace, &workspace_target)
             .wrap_err("failed to load ramekin configuration")?;
 
-        // Clear and reassemble the agent dir from pi config.
-        config::clear_agent_dir(&agent_dir).wrap_err("failed to clear agent directory")?;
-
-        let resolved_pi: Vec<config::ResolvedPiEntry> = config
-            .merged_pi()
-            .iter()
-            .map(|sv| sv.value.resolve())
-            .collect();
-        config::assemble_pi(&agent_dir, &resolved_pi).wrap_err("failed to assemble pi config")?;
-
-        // Write the system prompt file so pi can read it via --append-system-prompt.
-        let prompt_path = agent_dir.join("ramekin-prompt.md");
-        fs_err::write(&prompt_path, RAMEKIN_PROMPT).into_diagnostic()?;
-
         Ok(Self {
             workspace,
+            workspace_target,
             xdg,
-            agent_dir,
-            pi_data_dir,
+            pi_state_dir,
             repo_sessions_dir,
             cache_dir,
             custom_dockerfile,
@@ -179,19 +147,114 @@ impl Ramekin {
         })
     }
 
+    /// Materialize host-side state for a run: XDG directories and the
+    /// persistent pi auth file.
+    fn prepare(&self) -> Result<()> {
+        fs_err::create_dir_all(&self.pi_state_dir).into_diagnostic()?;
+        fs_err::create_dir_all(&self.repo_sessions_dir).into_diagnostic()?;
+        fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
+
+        // auth.json bind-mounts as a file, so it has to exist before the
+        // container starts. Migrate from the pre-redesign location
+        // (~/.config/ramekin/agent/auth.json) or start it empty.
+        let auth_file = self.pi_state_dir.join("auth.json");
+        if !auth_file.exists() {
+            let old_auth = self
+                .xdg
+                .get_config_home()
+                .map(|config_home| config_home.join("agent/auth.json"));
+            match old_auth.filter(|p| p.exists()) {
+                Some(old) => {
+                    info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
+                    fs_err::copy(&old, &auth_file).into_diagnostic()?;
+                }
+                None => {
+                    // create_new so two concurrent first runs can't clobber
+                    // each other; losing the race is fine, the file exists.
+                    use std::io::Write;
+                    match fs_err::OpenOptions::new()
+                        .write(true)
+                        .create_new(true)
+                        .open(&auth_file)
+                    {
+                        Ok(mut f) => f.write_all(b"{}").into_diagnostic()?,
+                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+                        Err(e) => return Err(e).into_diagnostic(),
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Session plumbing and agent-state mounts. Not a config layer: these
+    /// are forced, overriding any config mount at the same target.
+    ///
+    /// Pi state is ephemeral by default — a fresh empty writable dir per
+    /// session at the agent dir, with the allowlisted persistent pieces
+    /// (`auth.json`, per-repo `sessions/`) bind-mounted on top. Read-only
+    /// host-config mounts from the binary layer sit above that.
+    fn session_mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+        let agent = |rest: &str| format!("{}/{rest}", config::PI_AGENT_DIR);
+        vec![
+            config::ResolvedMount {
+                source: session_dir.join("agent"),
+                target: config::PI_AGENT_DIR.into(),
+                writable: true,
+            },
+            config::ResolvedMount {
+                source: self.pi_state_dir.join("auth.json"),
+                target: agent("auth.json"),
+                writable: true,
+            },
+            config::ResolvedMount {
+                source: self.repo_sessions_dir.clone(),
+                target: agent("sessions"),
+                writable: true,
+            },
+            config::ResolvedMount {
+                source: session_dir.join("ramekin-prompt.md"),
+                target: agent("ramekin-prompt.md"),
+                writable: false,
+            },
+            config::ResolvedMount {
+                source: self.workspace.clone(),
+                target: self.workspace_target.clone(),
+                writable: true,
+            },
+        ]
+    }
+
+    /// Merge config mounts with the forced session mounts, ordered
+    /// lexicographically by target so parents precede children.
+    fn final_mounts<'a>(
+        &'a self,
+        session_mounts: &'a [config::ResolvedMount],
+    ) -> Vec<&'a config::ResolvedMount> {
+        let mut by_target: BTreeMap<&str, &config::ResolvedMount> = self
+            .config
+            .merged_mounts()
+            .into_iter()
+            .map(|sv| (sv.value.target.as_str(), sv.value))
+            .collect();
+        for mount in session_mounts {
+            by_target.insert(mount.target.as_str(), mount);
+        }
+        by_target.into_values().collect()
+    }
+
     fn config(&self) -> Result<()> {
         println!("Workspace");
-        println!("  {}", self.workspace.display());
+        println!("  {} → {}", self.workspace.display(), self.workspace_target);
 
         println!();
         println!("Ramekin directories");
-        println!("  agent    {}", self.agent_dir.display());
-        println!("  data     {}", self.pi_data_dir.display());
+        println!("  pi state {}", self.pi_state_dir.display());
         println!("  sessions {}", self.repo_sessions_dir.display());
         println!("  cache    {}", self.cache_dir.display());
 
         let merged_mounts = self.config.merged_mounts();
-        let merged_pi = self.config.merged_pi();
         let merged_env = self.config.merged_env();
 
         let scope_label = |scope: config::Scope| -> String {
@@ -208,8 +271,7 @@ impl Ramekin {
         if !merged_mounts.is_empty() {
             println!();
             println!("Mounts");
-            let scopes: std::collections::BTreeSet<_> =
-                merged_mounts.iter().map(|sv| sv.scope).collect();
+            let scopes: BTreeSet<_> = merged_mounts.iter().map(|sv| sv.scope).collect();
             for scope in scopes {
                 println!("  {}", scope_label(scope));
                 for sv in merged_mounts.iter().filter(|sv| sv.scope == scope) {
@@ -222,12 +284,23 @@ impl Ramekin {
             }
         }
 
+        // Session mounts (sources materialize per run; shown with a placeholder)
+        let placeholder = self.cache_dir.join("sessions/<session>");
+        println!();
+        println!("Session mounts");
+        for mount in self.session_mounts(&placeholder) {
+            println!(
+                "    {} → {}",
+                mount.source.display(),
+                mount.display_target()
+            );
+        }
+
         // Environment
         if !merged_env.is_empty() {
             println!();
             println!("Environment");
-            let scopes: std::collections::BTreeSet<_> =
-                merged_env.iter().map(|sv| sv.scope).collect();
+            let scopes: BTreeSet<_> = merged_env.iter().map(|sv| sv.scope).collect();
             for scope in scopes {
                 println!("  {}", scope_label(scope));
                 for sv in merged_env.iter().filter(|sv| sv.scope == scope) {
@@ -236,37 +309,6 @@ impl Ramekin {
             }
         }
 
-        // Pi config
-        if !merged_pi.is_empty() {
-            println!();
-            println!("Pi config");
-            let scopes: std::collections::BTreeSet<_> =
-                merged_pi.iter().map(|sv| sv.scope).collect();
-            for scope in scopes {
-                println!("  {}", scope_label(scope));
-                for sv in merged_pi.iter().filter(|sv| sv.scope == scope) {
-                    let resolved = sv.value.resolve();
-                    let kind = if resolved.source.is_dir() {
-                        "dir"
-                    } else if resolved.source.is_file() {
-                        "file"
-                    } else {
-                        "missing"
-                    };
-                    let marker = if resolved.source.exists() {
-                        "✓"
-                    } else {
-                        "✗"
-                    };
-                    println!(
-                        "    {marker} {} → {} ({kind})",
-                        resolved.source.display(),
-                        resolved.target
-                    );
-                }
-            }
-        }
-
         println!();
         println!("Dockerfile");
         match &self.custom_dockerfile {
@@ -284,8 +326,9 @@ impl Ramekin {
     }
 
     fn run(&self, rebuild: bool, pi_args: &[String]) -> Result<()> {
-        info!(agent = %self.agent_dir.display(), repo = %self.repo_sessions_dir.display(), "directories");
-        info!(workspace = %self.workspace.display(), "starting agent");
+        info!(workspace = %self.workspace.display(), target = %self.workspace_target, "starting agent");
+
+        self.prepare()?;
 
         // Write the embedded Dockerfile to the cache directory
         let base_dockerfile = self.cache_dir.join("Dockerfile");
@@ -333,20 +376,23 @@ impl Ramekin {
             ),
         };
 
-        // Session-scoped: unique compose file and project name
+        // Session-scoped: compose file, rendered prompt, and a fresh empty
+        // agent dir, all under a random session id so concurrent runs don't
+        // interfere.
         let session_id = session_id();
         let session_dir = self
             .xdg
             .create_cache_directory(format!("sessions/{session_id}"))
             .into_diagnostic()
             .wrap_err("failed to create session directory")?;
+        let session_agent_dir = session_dir.join("agent");
+        fs_err::create_dir_all(&session_agent_dir).into_diagnostic()?;
 
-        let all_mounts: Vec<_> = self
-            .config
-            .merged_mounts()
-            .into_iter()
-            .map(|sv| sv.value)
-            .collect();
+        let prompt = RAMEKIN_PROMPT.replace("{{WORKSPACE_PATH}}", &self.workspace_target);
+        fs_err::write(session_dir.join("ramekin-prompt.md"), prompt).into_diagnostic()?;
+
+        let session_mounts = self.session_mounts(&session_dir);
+        let all_mounts = self.final_mounts(&session_mounts);
         let env_vars = self.config.merged_env();
         let compose = generate_compose(
             &dockerfile,
@@ -354,11 +400,24 @@ impl Ramekin {
             &all_mounts,
             &env_vars,
             &image,
+            &self.workspace_target,
             pi_args,
         );
         let compose_file = session_dir.join("compose.yml");
         fs_err::write(&compose_file, &compose).into_diagnostic()?;
 
+        // Mount targets inside the agent dir show up on the host as empty
+        // artifacts Docker creates to serve as mount points; the teardown
+        // report has to know to skip them.
+        let agent_dir_mountpoints: BTreeSet<PathBuf> = all_mounts
+            .iter()
+            .filter_map(|m| {
+                m.target
+                    .strip_prefix(&format!("{}/", config::PI_AGENT_DIR))
+                    .map(PathBuf::from)
+            })
+            .collect();
+
         let project_name = format!("ramekin-{session_id}");
         let docker_compose = |args: &[&str]| -> Result<Command> {
             let mut cmd = Command::new("docker");
@@ -412,6 +471,18 @@ impl Ramekin {
             error!("docker compose down failed ({})", down_status);
         }
 
+        // Anything the agent wrote to its session-scoped dir is about to be
+        // discarded; log it so a path that deserves persistence gets noticed
+        // instead of silently vanishing.
+        match discarded_writes(&session_agent_dir, &agent_dir_mountpoints) {
+            Ok(paths) => {
+                for path in paths {
+                    warn!(path = %path.display(), "discarding session-scoped agent write");
+                }
+            }
+            Err(e) => error!("failed to inspect session agent dir: {e}"),
+        }
+
         if let Err(e) = fs_err::remove_dir_all(&session_dir) {
             error!("failed to clean up session dir: {e}");
         }
@@ -428,6 +499,47 @@ impl Ramekin {
 // Helpers
 // ---------------------------------------------------------------------------
 
+/// Collect every file the agent wrote into its session-scoped agent dir.
+///
+/// The dir starts empty, so anything found here (other than the empty
+/// artifacts Docker created as mount points, listed in `mountpoints` as
+/// agent-dir-relative paths) is durable-looking state the agent produced
+/// that ramekin is about to throw away. Logging these is the learning loop
+/// for promoting a path into the persistent set — or confirming it's junk.
+fn discarded_writes(agent_dir: &Path, mountpoints: &BTreeSet<PathBuf>) -> Result<Vec<PathBuf>> {
+    fn walk(
+        dir: &Path,
+        root: &Path,
+        skip: &BTreeSet<PathBuf>,
+        found: &mut Vec<PathBuf>,
+    ) -> Result<()> {
+        for entry in fs_err::read_dir(dir).into_diagnostic()? {
+            let entry = entry.into_diagnostic()?;
+            let path = entry.path();
+            let rel = path
+                .strip_prefix(root)
+                .expect("walk stays under root")
+                .to_path_buf();
+            // A mount point (and everything a mount put under it) is not an
+            // agent write; skip the whole subtree.
+            if skip.contains(&rel) {
+                continue;
+            }
+            if entry.file_type().into_diagnostic()?.is_dir() {
+                walk(&path, root, skip, found)?;
+            } else {
+                found.push(rel);
+            }
+        }
+        Ok(())
+    }
+
+    let mut found = Vec::new();
+    walk(agent_dir, agent_dir, mountpoints, &mut found)?;
+    found.sort();
+    Ok(found)
+}
+
 /// Generate a random session ID for scoping the compose project and cache dir.
 fn session_id() -> String {
     format!("{:08x}", fastrand::u32(..))
@@ -480,8 +592,9 @@ struct AgentService {
     image: String,
     stdin_open: bool,
     tty: bool,
+    working_dir: String,
     environment: Vec<String>,
-    volumes: Vec<String>,
+    volumes: Vec<VolumeBind>,
     command: Vec<String>,
 }
 
@@ -491,6 +604,17 @@ struct BuildConfig {
     dockerfile: String,
 }
 
+/// Long-form compose bind mount. Avoids the `source:target[:ro]` short form,
+/// which can't represent paths containing colons.
+#[derive(Serialize)]
+struct VolumeBind {
+    #[serde(rename = "type")]
+    kind: &'static str,
+    source: String,
+    target: String,
+    read_only: bool,
+}
+
 /// Generate a Docker Compose config with all volume mounts.
 fn generate_compose(
     dockerfile: &Path,
@@ -498,9 +622,18 @@ fn generate_compose(
     mounts: &[&config::ResolvedMount],
     env_vars: &[config::ScopedValue<(&str, &str)>],
     image: &str,
+    working_dir: &str,
     pi_args: &[String],
 ) -> String {
-    let volumes: Vec<String> = mounts.iter().map(|m| m.to_volume_string()).collect();
+    let volumes: Vec<VolumeBind> = mounts
+        .iter()
+        .map(|m| VolumeBind {
+            kind: "bind",
+            source: m.source.display().to_string(),
+            target: m.target.clone(),
+            read_only: !m.writable,
+        })
+        .collect();
 
     let environment: Vec<String> = env_vars
         .iter()
@@ -508,15 +641,12 @@ fn generate_compose(
         .collect();
 
     // Always pass --append-system-prompt for the ramekin container context.
-    // The prompt file is written into the agent dir which is mounted at /root/.pi/agent.
-    let prompt_path = "/root/.pi/agent/ramekin-prompt.md";
-    let command: Vec<String> = [
-        "--append-system-prompt".to_string(),
-        prompt_path.to_string(),
-    ]
-    .into_iter()
-    .chain(pi_args.iter().cloned())
-    .collect();
+    // The rendered prompt is mounted read-only into the agent dir.
+    let prompt_path = format!("{}/ramekin-prompt.md", config::PI_AGENT_DIR);
+    let command: Vec<String> = ["--append-system-prompt".to_string(), prompt_path]
+        .into_iter()
+        .chain(pi_args.iter().cloned())
+        .collect();
 
     let config = ComposeConfig {
         services: Services {
@@ -528,6 +658,7 @@ fn generate_compose(
                 image: image.to_string(),
                 stdin_open: true,
                 tty: true,
+                working_dir: working_dir.to_string(),
                 environment,
                 volumes,
                 command,
@@ -568,11 +699,67 @@ mod tests {
             &[],
             &[],
             "ramekin-agent-lit-rs-deadbeef",
+            "/workspace/lit-rs-deadbeef",
             &[],
         );
         assert!(
             yaml.contains("image: ramekin-agent-lit-rs-deadbeef"),
             "compose did not carry the supplied image tag:\n{yaml}"
         );
+        assert!(
+            yaml.contains("working_dir: /workspace/lit-rs-deadbeef"),
+            "compose did not set working_dir:\n{yaml}"
+        );
+    }
+
+    #[test]
+    fn generate_compose_long_form_binds() {
+        let mount = config::ResolvedMount {
+            source: PathBuf::from("/host/.config/git"),
+            target: "/root/.config/git".into(),
+            writable: false,
+        };
+        let yaml = generate_compose(
+            Path::new("/cache/Dockerfile"),
+            Path::new("/cache"),
+            &[&mount],
+            &[],
+            "ramekin-agent",
+            "/workspace/x-1",
+            &[],
+        );
+        assert!(yaml.contains("type: bind"), "{yaml}");
+        assert!(yaml.contains("source: /host/.config/git"), "{yaml}");
+        assert!(yaml.contains("target: /root/.config/git"), "{yaml}");
+        assert!(yaml.contains("read_only: true"), "{yaml}");
+    }
+
+    #[test]
+    fn discarded_writes_skips_mountpoint_artifacts() {
+        let dir = tempfile::tempdir().unwrap();
+        // Mount point artifacts docker would leave behind.
+        fs_err::write(dir.path().join("auth.json"), "").unwrap();
+        fs_err::create_dir_all(dir.path().join("sessions")).unwrap();
+        // Genuine agent writes.
+        fs_err::write(dir.path().join("scratch.txt"), "x").unwrap();
+        fs_err::create_dir_all(dir.path().join("cache")).unwrap();
+        fs_err::write(dir.path().join("cache/blob"), "y").unwrap();
+
+        let mountpoints: BTreeSet<PathBuf> =
+            [PathBuf::from("auth.json"), PathBuf::from("sessions")]
+                .into_iter()
+                .collect();
+        let found = discarded_writes(dir.path(), &mountpoints).unwrap();
+        assert_eq!(
+            found,
+            vec![PathBuf::from("cache/blob"), PathBuf::from("scratch.txt")]
+        );
+    }
+
+    #[test]
+    fn discarded_writes_empty_dir_reports_nothing() {
+        let dir = tempfile::tempdir().unwrap();
+        let found = discarded_writes(dir.path(), &BTreeSet::new()).unwrap();
+        assert!(found.is_empty());
     }
 }