Isolate sessions by user/repo/session scope
Pi data was shared across all workspaces since every mount appeared
as /workspace. Now auth is user-scoped, session history is per-repo
(keyed by workspace path hash), and each invocation gets its own
compose file and docker project name.

Assisted-by: Claude Opus 4.6 via pi
change mqonnprvnlmnqzlqvttlksrwmuymsxot
commit 63bdabd4780ab0c82e3cf98b91da17eab371ae7d
author Alpha Chen <alpha@kejadlen.dev>
date
parent oltqnvln
diff --git a/src/main.rs b/src/main.rs
index c18afff..b620d45 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,5 @@
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
 use std::path::{Path, PathBuf};
 use std::process::{Command, ExitCode, Stdio};
 
@@ -42,30 +44,41 @@ fn run() -> Result<()> {
         .wrap_err_with(|| format!("workspace path does not exist: {}", cli.workspace.display()))?;
 
     let xdg = xdg::BaseDirectories::with_prefix("ramekin");
-    let pi_data_dir = xdg
-        .create_data_directory("")
-        .wrap_err("failed to create pi data directory")?;
 
-    let pi_config_dir = xdg
+    // User-scoped: config files shared across all sessions
+    let config_dir = xdg
         .create_config_directory("")
-        .wrap_err("failed to create pi config directory")?;
+        .wrap_err("failed to create config directory")?;
 
-    // Seed empty config files if they don't exist
     for file in ["settings.json", "keybindings.json"] {
-        let path = pi_config_dir.join(file);
-        if !path.exists() {
+        let path = config_dir.join(file);
+        if !path.is_file() {
             fs_err::write(&path, "{}")?;
         }
     }
-    let agents_md = pi_config_dir.join("AGENTS.md");
+    let agents_md = config_dir.join("AGENTS.md");
     if !agents_md.exists() {
         fs_err::write(&agents_md, "")?;
     }
 
-    info!(data = %pi_data_dir.display(), config = %pi_config_dir.display(), "pi directories");
+    // User-scoped: auth shared across all sessions
+    let auth_file = xdg
+        .place_data_file("auth.json")
+        .wrap_err("failed to create auth file path")?;
+    if !auth_file.exists() {
+        fs_err::write(&auth_file, "{}")?;
+    }
+
+    // Repo-scoped: per-workspace pi data dir for sessions
+    let repo_slug = repo_slug(&workspace);
+    let repo_data_dir = xdg
+        .create_data_directory(format!("repos/{repo_slug}"))
+        .wrap_err("failed to create repo data directory")?;
+
+    info!(config = %config_dir.display(), repo = %repo_data_dir.display(), "directories");
     info!(workspace = %workspace.display(), "starting agent");
 
-    // Write embedded files to XDG cache so docker compose can find them
+    // User-scoped: embedded assets
     let cache_dir = xdg
         .create_cache_directory("")
         .wrap_err("failed to create cache directory")?;
@@ -97,21 +110,30 @@ fn run() -> Result<()> {
         (base_dockerfile, cache_dir.clone())
     };
 
+    // Session-scoped: unique compose file and project name
+    let session_id = session_id();
+    let session_dir = xdg
+        .create_cache_directory(format!("sessions/{session_id}"))
+        .wrap_err("failed to create session directory")?;
+
     let compose = generate_compose(
         &workspace,
         &dockerfile,
         &build_context,
-        &pi_data_dir,
-        &pi_config_dir,
+        &repo_data_dir,
+        &auth_file,
+        &config_dir,
         &extension_path,
     );
-    let compose_file = cache_dir.join("compose.yml");
+    let compose_file = session_dir.join("compose.yml");
     fs_err::write(&compose_file, &compose)?;
 
+    let project_name = format!("ramekin-{session_id}");
     let docker_compose = |args: &[&str]| -> Result<Command> {
         let mut cmd = Command::new("docker");
         cmd.args(["compose", "-f"])
             .arg(&compose_file)
+            .args(["--project-name", &project_name])
             .args(args);
         Ok(cmd)
     };
@@ -140,6 +162,11 @@ fn run() -> Result<()> {
         error!("docker compose down failed ({})", down_status);
     }
 
+    // Clean up session directory
+    if let Err(e) = fs_err::remove_dir_all(&session_dir) {
+        error!("failed to clean up session dir: {e}");
+    }
+
     if !status.success() {
         bail!("agent exited with error ({})", status);
     }
@@ -147,6 +174,23 @@ fn run() -> Result<()> {
     Ok(())
 }
 
+/// Generate a short random session ID.
+fn session_id() -> String {
+    format!("{:x}", std::process::id())
+}
+
+/// Create a slug for a workspace path: `<dirname>-<hash>`.
+fn repo_slug(workspace: &Path) -> String {
+    let name = workspace
+        .file_name()
+        .map(|n| n.to_string_lossy().to_string())
+        .unwrap_or_else(|| "root".into());
+    let mut hasher = DefaultHasher::new();
+    workspace.hash(&mut hasher);
+    let hash = hasher.finish();
+    format!("{name}-{hash:08x}")
+}
+
 #[derive(Serialize)]
 struct ComposeConfig {
     services: Services,
@@ -177,13 +221,15 @@ fn generate_compose(
     workspace: &Path,
     dockerfile: &Path,
     build_context: &Path,
-    data_dir: &Path,
+    repo_data_dir: &Path,
+    auth_file: &Path,
     config_dir: &Path,
     extension_path: &Path,
 ) -> String {
     let mut volumes = vec![
         format!("{}:/workspace", workspace.display()),
-        format!("{}:/root/.pi", data_dir.display()),
+        format!("{}:/root/.pi", repo_data_dir.display()),
+        format!("{}:/root/.pi/auth.json", auth_file.display()),
         format!(
             "{}/settings.json:/root/.pi/agent/settings.json",
             config_dir.display()