From 7dfdf15a12d35e3fe6e3de2cbfaf4cc72bc749bf Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Wed, 19 Aug 2026 00:02:18 -0700 Subject: [PATCH] [silverwood] Make sessions record how to run; give spawn an algebraic tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename AgentKind to SessionKind and add two durable kinds: claude-code-noninteractive (carrying an explicit run_direnv_exec) and disk-space. Replace the flat `spawn [sid] [--resume]` with a subcommand tree — from-id, plain-shell, disk-space, claude-code {first-run|resume}, and claude-code-noninteractive --run-direnv-exec {first-run|resume} — with --override-working-directory on the cwd-derived kinds. The interactive kinds run inside the user's login-interactive shell ( -l -i -c '; exec …') and synchronously run the shell's own prompt hooks, so silverwood is direnv-blind: a checkout's .envrc loads exactly as it would at the user's prompt, and a user with no direnv hook gets none. The old `direnv exec` behavior moves to the explicit claude-code-noninteractive kind. `spawn from-id` resolves a durable session into a plan, deciding first-run vs resume from whether a Claude transcript exists on disk. Wire format and schema version are unchanged (additive enum variants). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DY9N9STx6HMGtSEJauX1KG --- .../crates/silverwood-cli/src/main.rs | 248 ++++++++++--- .../crates/silverwood-cli/tests/cli.rs | 118 ++++++- .../crates/silverwood-cli/tests/e2e.rs | 109 +++--- .../crates/silverwood-core/src/doc.rs | 29 +- .../crates/silverwood-core/src/error.rs | 6 + .../crates/silverwood-core/src/forest.rs | 89 ++++- .../crates/silverwood-core/src/lib.rs | 11 +- .../crates/silverwood-core/src/spawn.rs | 331 ++++++++++++------ .../silverwood-core/src/tests/convergence.rs | 4 +- .../crates/silverwood-core/src/tests/mod.rs | 4 +- .../crates/silverwood-core/src/workstream.rs | 49 ++- .../silverwood-core/tests/associated_data.rs | 151 +++++++- 12 files changed, 900 insertions(+), 249 deletions(-) diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index d045bbb..de52bd6 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -13,9 +13,10 @@ use std::str::FromStr; use clap::{Parser, Subcommand, ValueEnum}; use silverwood_core::{ - agent_shell_plan, base_shell_plan, AbsolutePath, AgentKind, AgentSession, CheckoutExtent, - CheckoutState, DoctorReport, Forest, HttpsGitUrl, LocationWithinForest, NewCheckoutMode, - NewKind, NewWorkstream, SpawnSeed, UpgradeReport, Workstream, WorkstreamId, DOC_SCHEMA_VERSION, + claude_code_noninteractive_plan, claude_code_plan, disk_space_plan, plain_shell_plan, + AbsolutePath, AgentSession, CheckoutExtent, ClaudeRun, DoctorReport, Forest, HttpsGitUrl, + LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SessionKind, ShellPlan, + SpawnSeed, UpgradeReport, Workstream, WorkstreamId, DOC_SCHEMA_VERSION, }; /// Frontend-agnostic backend for the code you work on and the agent sessions @@ -87,22 +88,108 @@ enum Command { /// fixed shape. Human output enumerates each leaf invocation. NewSchema, - /// Exec the interactive agent shell for a session in its checkout. The command - /// is chosen from the checkout mode (env-scrubbed; `direnv exec` for the - /// direnv-unsafe mode) — this replaces the process with the agent, so on - /// success it never returns. Omit `session_id` for a bare login shell. + /// Run a session: exec its shell in a checkout, replacing this process (so on success + /// it never returns; the caller's PTY then tracks the session directly). `from-id` + /// looks up a durable session and runs it the way it records itself; the per-kind + /// subcommands run a kind directly in the current directory (or + /// `--override-working-directory`). The environment is a scrubbed login env either way. Spawn { - /// The workstream id whose checkout to spawn in (from `silverwood ls`). - workstream_id: String, - /// The Claude session id to run (`claude --session-id`/`--resume`); omit - /// for a plain login shell in the checkout. - session_id: Option, - /// Resume the session (`claude --resume`) instead of starting it fresh. + #[command(subcommand)] + what: SpawnCommand, + }, +} + +/// `silverwood spawn ` — how to run a session. `from-id` resolves a durable +/// session object; the per-kind leaves run a kind directly and take +/// `--override-working-directory`. The kind-selecting shape mirrors the `SessionKind`s. +#[derive(Subcommand)] +enum SpawnCommand { + /// Run the durable session `` on `--workstream ` the way it records + /// itself (its kind, and first-run vs resume, are decided by silverwood). Runs in the + /// workstream's checkout, so it takes no `--override-working-directory`. + FromId { + /// The session id to run (from `silverwood session ls`). + session_id: String, + /// The workstream the session is attached to (from `silverwood ls`). #[arg(long)] - resume: bool, + workstream: String, + }, + /// A plain interactive login shell. + PlainShell { + #[command(flatten)] + wd: WorkingDir, + }, + /// A disk-space monitor (a `df` refresh loop), run via the same interactive-shell + /// mechanism as `plain-shell`. + DiskSpace { + #[command(flatten)] + wd: WorkingDir, + }, + /// A Claude Code session, run as a thin wrapper over your login-interactive shell. + ClaudeCode { + #[command(subcommand)] + run: ClaudeRunArg, + }, + /// A Claude Code session, run non-interactively (claude directly), with explicit + /// control over loading the checkout's `.envrc`. + ClaudeCodeNoninteractive { + /// Whether to wrap claude in `direnv exec ` — required, `true` or `false`. + #[arg(long, action = clap::ArgAction::Set, required = true)] + run_direnv_exec: bool, + #[command(subcommand)] + run: ClaudeRunArg, }, } +/// First-run vs resume for a claude-code spawn — carries the claude session id and the +/// per-leaf working directory. +#[derive(Subcommand)] +enum ClaudeRunArg { + /// First launch of this session id (`claude --session-id`, which creates the conversation). + FirstRun { + /// The Claude session id to run. + session_id: String, + #[command(flatten)] + wd: WorkingDir, + }, + /// Resume an existing conversation (`claude --resume`). + Resume { + /// The Claude session id to run. + session_id: String, + #[command(flatten)] + wd: WorkingDir, + }, +} + +impl ClaudeRunArg { + /// Decompose into (session id, run mode, working dir). + fn parts(self) -> (String, ClaudeRun, WorkingDir) { + match self { + ClaudeRunArg::FirstRun { session_id, wd } => (session_id, ClaudeRun::FirstRun, wd), + ClaudeRunArg::Resume { session_id, wd } => (session_id, ClaudeRun::Resume, wd), + } + } +} + +/// The working directory for a cwd-derived spawn kind: `--override-working-directory` if +/// given, else this process's own cwd. (Not accepted by `from-id`, which uses the checkout.) +#[derive(clap::Args)] +struct WorkingDir { + /// Working directory to run in (default: this process's own cwd). + #[arg(long, value_name = "PATH")] + override_working_directory: Option, +} + +impl WorkingDir { + /// Resolve to a concrete path: the override, else the process's current directory. + fn resolve(self) -> Result> { + match self.override_working_directory { + Some(path) => Ok(path), + None => Ok(std::env::current_dir()?.to_string_lossy().into_owned()), + } + } +} + /// Positional args are shared across kv subcommands: ` [KEY] [VALUE]`. /// `id` is the workstream id; `namespace` is a reverse-DNS, frontend-owned prefix /// (the `app.andref.silverwood.*` prefix is reserved). @@ -237,6 +324,30 @@ enum SessionCreate { #[arg(long)] name: Option, }, + /// A Claude Code session run non-interactively (claude directly, not inside the user's + /// interactive shell), with explicit `--run-direnv-exec` control over loading `.envrc`. + ClaudeCodeNoninteractive { + /// The workstream id to attach the session to (from `silverwood ls`). + id: String, + /// The Claude Code session id to record. + session_id: String, + /// Display name for the session (defaults to the session id). + #[arg(long)] + name: Option, + /// Whether spawning runs claude under `direnv exec ` — required, `true`/`false`. + #[arg(long, action = clap::ArgAction::Set, required = true)] + run_direnv_exec: bool, + }, + /// A disk-space monitor session (a `df` refresh loop). `session_id` is a caller-minted id. + DiskSpace { + /// The workstream id to attach the session to (from `silverwood ls`). + id: String, + /// The caller-minted session id to record the monitor under. + session_id: String, + /// Display name for the session (defaults to the session id). + #[arg(long)] + name: Option, + }, } /// The workstream variant (kind) to create — the first `new` subcommand level. Only @@ -515,11 +626,7 @@ fn run(cli: Cli) -> CliResult { Command::Kv(cmd) => run_kv(&forest, json, cmd), Command::Session(cmd) => run_session(&forest, json, cmd), - Command::Spawn { - workstream_id, - session_id, - resume, - } => run_spawn(&forest, json, &workstream_id, session_id.as_deref(), resume), + Command::Spawn { what } => run_spawn(&forest, json, what), Command::UpgradeForest { dry_run } => { let reports = forest.upgrade_all(dry_run)?; @@ -638,6 +745,8 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { let id = match &cmd { SessionCommand::Create(SessionCreate::ClaudeCode { id, .. }) | SessionCommand::Create(SessionCreate::PlainShell { id, .. }) + | SessionCommand::Create(SessionCreate::ClaudeCodeNoninteractive { id, .. }) + | SessionCommand::Create(SessionCreate::DiskSpace { id, .. }) | SessionCommand::Ls { id } | SessionCommand::Rename { id, .. } | SessionCommand::Rm { id, .. } @@ -651,13 +760,41 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { session_id, name, .. }) => { let name = name.unwrap_or_else(|| session_id.clone()); - forest.create_session(id, &session_id, AgentKind::ClaudeCode { lock: None }, &name)?; + forest.create_session( + id, + &session_id, + SessionKind::ClaudeCode { lock: None }, + &name, + )?; } SessionCommand::Create(SessionCreate::PlainShell { session_id, name, .. }) => { let name = name.unwrap_or_else(|| session_id.clone()); - forest.create_session(id, &session_id, AgentKind::PlainShell {}, &name)?; + forest.create_session(id, &session_id, SessionKind::PlainShell {}, &name)?; + } + SessionCommand::Create(SessionCreate::ClaudeCodeNoninteractive { + session_id, + name, + run_direnv_exec, + .. + }) => { + let name = name.unwrap_or_else(|| session_id.clone()); + forest.create_session( + id, + &session_id, + SessionKind::ClaudeCodeNoninteractive { + lock: None, + run_direnv_exec, + }, + &name, + )?; + } + SessionCommand::Create(SessionCreate::DiskSpace { + session_id, name, .. + }) => { + let name = name.unwrap_or_else(|| session_id.clone()); + forest.create_session(id, &session_id, SessionKind::DiskSpace {}, &name)?; } SessionCommand::Rename { session_id, name, .. @@ -691,39 +828,50 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { Ok(()) } -/// Build the interactive-shell plan for a session (from the checkout mode) and -/// `exec` it, replacing this process with the agent — so the caller's PTY tracks -/// the agent's lifetime directly. `--json` prints the resolved plan instead of -/// exec'ing (for inspection/tests). The env/command construction lives in -/// `silverwood-core`; reading the seed vars from the environment is frontend +/// Build the [`ShellPlan`] for the chosen `spawn` subcommand and `exec` it (see +/// [`exec_plan`]). `from-id` asks core to resolve a durable session into a plan (core owns +/// *how* each kind runs); the per-kind subcommands call the matching plan builder with a +/// resolved working directory. Reading the seed/config-dir from the environment is frontend /// policy (like [`resolve_forest_dir`]), so it stays here. -fn run_spawn( - forest: &Forest, - json: bool, - id: &str, - session_id: Option<&str>, - resume: bool, -) -> CliResult { - let ws = forest.get(parse_id(id)?)?; - let mode = ws - .body - .mode() - .ok_or("workstream has no checkout to spawn in")?; - if mode.state() != CheckoutState::Ready { - return Err(format!("checkout is not ready (state: {})", enum_str(mode.state())).into()); - } - let Some(LocationWithinForest::BasicForest { path: cwd }) = - ws.body.location().map(|loc| &loc.within) - else { - return Err("workstream has no checkout location".into()); - }; - +fn run_spawn(forest: &Forest, json: bool, what: SpawnCommand) -> CliResult { let seed = spawn_seed()?; - let plan = match session_id { - Some(session_id) => agent_shell_plan(mode, cwd, session_id, resume, &seed), - None => base_shell_plan(cwd, &seed), + let plan = match what { + SpawnCommand::FromId { + session_id, + workstream, + } => forest.spawn_plan_from_session( + parse_id(&workstream)?, + &session_id, + &seed, + &resolve_claude_config_dir()?, + )?, + SpawnCommand::PlainShell { wd } => plain_shell_plan(&wd.resolve()?, &seed), + SpawnCommand::DiskSpace { wd } => disk_space_plan(&wd.resolve()?, &seed), + SpawnCommand::ClaudeCode { run } => { + let (session_id, run, wd) = run.parts(); + claude_code_plan(&wd.resolve()?, &session_id, run, &seed) + } + SpawnCommand::ClaudeCodeNoninteractive { + run_direnv_exec, + run, + } => { + let (session_id, run, wd) = run.parts(); + claude_code_noninteractive_plan( + &wd.resolve()?, + &session_id, + run, + run_direnv_exec, + &seed, + ) + } }; + exec_plan(json, plan) +} +/// `exec` the resolved plan, replacing this process so the caller's PTY tracks the session +/// directly — or, with `--json`, print the plan and return (for inspection/tests). Shared by +/// every `spawn` subcommand. +fn exec_plan(json: bool, plan: ShellPlan) -> CliResult { if json { println!("{}", serde_json::to_string_pretty(&plan)?); return Ok(()); diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index 58c06a1..acfb872 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -308,22 +308,132 @@ fn remove_rejects_bad_and_absent_ids() { } #[test] -fn spawn_rejects_bad_and_absent_ids() { +fn spawn_from_id_rejects_bad_and_absent_ids() { let dir = forest(); - // Bad id → parse error, before any forest work or exec. - let stderr = fails(&dir, &["spawn", "not-a-uuid", "sess-1"]); + // Bad workstream id → parse error, before any forest work or exec. + let stderr = fails( + &dir, + &["spawn", "from-id", "sess-1", "--workstream", "not-a-uuid"], + ); assert!(stderr.contains("invalid workstream id"), "got: {stderr}"); - // Well-formed but absent id → not-found (there is no checkout to spawn in). + // Well-formed but absent id → not-found (there is no workstream to spawn from). fails( &dir, &[ "spawn", + "from-id", + "sess-1", + "--workstream", "uuidv7_01999999-0000-7000-8000-000000000000", + ], + ); +} + +/// The direct spawn kinds build their plan from `--override-working-directory` (no +/// checkout needed), so their `--json` plan shape is testable in the sandbox. We assert on +/// shell-agnostic parts only — the interactive kinds' `program` is the login shell, which +/// varies by environment. +#[test] +fn spawn_direct_variants_reflect_the_kind() { + let dir = forest(); + + // plain-shell: an interactive login shell (` -l`). + let plan = json( + &dir, + &[ + "--json", + "spawn", + "plain-shell", + "--override-working-directory", + "/tmp/x", + ], + ); + assert_eq!(plan["args"], serde_json::json!(["-l"])); + assert_eq!(plan["cwd"], "/tmp/x"); + + // claude-code (interactive): runs inside the login-interactive shell, chaining `exec claude`. + let plan = json( + &dir, + &[ + "--json", + "spawn", + "claude-code", + "first-run", "sess-1", + "--override-working-directory", + "/tmp/x", ], ); + let args = plan["args"].as_array().unwrap(); + assert_eq!(args[0], "-l"); + assert_eq!(args[1], "-i"); + assert_eq!(args[2], "-c"); + assert!( + args[3] + .as_str() + .unwrap() + .contains("exec claude --session-id 'sess-1'"), + "script: {}", + args[3] + ); + + // claude-code-noninteractive: claude directly when direnv off … + let plan = json( + &dir, + &[ + "--json", + "spawn", + "claude-code-noninteractive", + "--run-direnv-exec", + "false", + "resume", + "sess-1", + "--override-working-directory", + "/tmp/x", + ], + ); + assert_eq!(plan["program"], "claude"); + assert_eq!(plan["args"], serde_json::json!(["--resume", "sess-1"])); + + // … and under `direnv exec ` when on. + let plan = json( + &dir, + &[ + "--json", + "spawn", + "claude-code-noninteractive", + "--run-direnv-exec", + "true", + "first-run", + "sess-1", + "--override-working-directory", + "/tmp/x", + ], + ); + assert_eq!(plan["program"], "direnv"); + assert_eq!( + plan["args"], + serde_json::json!(["exec", "/tmp/x", "claude", "--session-id", "sess-1"]) + ); + + // disk-space: a `df` loop via the interactive shell. + let plan = json( + &dir, + &[ + "--json", + "spawn", + "disk-space", + "--override-working-directory", + "/tmp/x", + ], + ); + assert!( + plan["args"][3].as_str().unwrap().contains("df -h"), + "script: {}", + plan["args"][3] + ); } #[test] diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index 020697c..1cd6d3d 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -288,17 +288,20 @@ fn new_direnv_unsafe_mode_is_ready() { assert!(Path::new(loc).join(".jj").is_dir(), ".jj missing"); } -/// `spawn --json` resolves the interactive-shell plan from the workstream's -/// stored checkout mode: plain `claude` for jj-colocated, `direnv exec ` for -/// the direnv-unsafe mode; `--resume` flips the claude flag; and omitting the -/// session id yields the base login shell. Needs a real (ready) checkout — hence -/// ignored. (The pure mode→argv logic is unit-tested in `silverwood-core`.) +/// `spawn from-id --json` resolves a durable session into a plan: it reads the session's +/// kind and runs it in the workstream's checkout, deciding first-run vs resume from whether +/// a Claude transcript exists under `CLAUDE_CONFIG_DIR`. Needs a real (ready) checkout — +/// hence ignored. (The pure kind→plan builders are unit-tested in `silverwood-core`; +/// direnv-unsafe checkout modes no longer affect the plan — the interactive claude-code kind +/// is direnv-blind, and explicit `direnv exec` lives on `claude-code-noninteractive`.) #[test] #[ignore = "network + jj; run via `cargo test -- --ignored`"] -fn spawn_plan_reflects_checkout_mode() { +fn spawn_from_id_resolves_each_session_kind() { let dir = forest(); + // An empty CLAUDE_CONFIG_DIR ⇒ no transcripts ⇒ claude sessions resolve to first-run. + let claude_cfg = forest(); + let cfg = claude_cfg.path().to_str().unwrap(); - // Plain jj-colocated → `claude --session-id `, run in the checkout. let ws = json( &dir, &[ @@ -310,47 +313,75 @@ fn spawn_plan_reflects_checkout_mode() { "jj-colocated", EXAMPLE_SOURCE, "--name", - "plain", + "work", ], ); let id = ws["id"].as_str().unwrap(); - let plan = json(&dir, &["--json", "spawn", id, "sess-1"]); - assert_eq!(plan["program"], "claude"); - assert_eq!(plan["args"], serde_json::json!(["--session-id", "sess-1"])); - assert_eq!(plan["cwd"], ws["location"]["within"]["path"]); - - // `--resume` flips the claude flag. - let resumed = json(&dir, &["--json", "spawn", id, "sess-1", "--resume"]); - assert_eq!(resumed["args"], serde_json::json!(["--resume", "sess-1"])); - - // The base-shell variant (no session id) runs a login shell in the checkout. - let base = json(&dir, &["--json", "spawn", id]); - assert!(base["program"].as_str().is_some_and(|p| !p.is_empty())); - assert_eq!(base["args"], serde_json::json!(["-l"])); - - // Direnv-unsafe → claude wrapped in `direnv exec ` (cwd is its own argv). - let ws2 = json( + let cwd = ws["location"]["within"]["path"].as_str().unwrap(); + + // claude-code (interactive): runs inside the login-interactive shell, first-run (no transcript). + ok(&dir, &["session", "create", "claude-code", id, "cc-1"]); + let plan = json_env( + &dir, + &[("CLAUDE_CONFIG_DIR", cfg)], + &["--json", "spawn", "from-id", "cc-1", "--workstream", id], + ); + assert_eq!(plan["args"][0], "-l"); + assert_eq!(plan["args"][1], "-i"); + assert_eq!(plan["args"][2], "-c"); + assert!(plan["args"][3] + .as_str() + .unwrap() + .contains("exec claude --session-id 'cc-1'")); + assert_eq!(plan["cwd"], cwd); + + // claude-code-noninteractive with direnv on: `direnv exec claude --session-id`. + ok( &dir, &[ - "--json", - "new", - "basic", - "--checkout-extent", - "full", - "jj-colocated-direnv-unsafe", - EXAMPLE_SOURCE, - "--name", - "with-direnv", + "session", + "create", + "claude-code-noninteractive", + id, + "ni-1", + "--run-direnv-exec", + "true", ], ); - let id2 = ws2["id"].as_str().unwrap(); - let cwd2 = ws2["location"]["within"]["path"].as_str().unwrap(); - let plan2 = json(&dir, &["--json", "spawn", id2, "sess-2"]); - assert_eq!(plan2["program"], "direnv"); + let plan = json_env( + &dir, + &[("CLAUDE_CONFIG_DIR", cfg)], + &["--json", "spawn", "from-id", "ni-1", "--workstream", id], + ); + assert_eq!(plan["program"], "direnv"); assert_eq!( - plan2["args"], - serde_json::json!(["exec", cwd2, "claude", "--session-id", "sess-2"]) + plan["args"], + serde_json::json!(["exec", cwd, "claude", "--session-id", "ni-1"]) + ); + + // plain-shell: a login shell in the checkout. + ok(&dir, &["session", "create", "plain-shell", id, "sh-1"]); + let plan = json( + &dir, + &["--json", "spawn", "from-id", "sh-1", "--workstream", id], ); + assert_eq!(plan["args"], serde_json::json!(["-l"])); + assert_eq!(plan["cwd"], cwd); + + // A claude session WITH a transcript on disk resolves to resume, not first-run. + let proj = claude_cfg.path().join("projects").join("-any"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(proj.join("cc-2.jsonl"), "{}\n").unwrap(); + ok(&dir, &["session", "create", "claude-code", id, "cc-2"]); + let plan = json_env( + &dir, + &[("CLAUDE_CONFIG_DIR", cfg)], + &["--json", "spawn", "from-id", "cc-2", "--workstream", id], + ); + assert!(plan["args"][3] + .as_str() + .unwrap() + .contains("exec claude --resume 'cc-2'")); } #[test] diff --git a/experimental/silverwood/crates/silverwood-core/src/doc.rs b/experimental/silverwood/crates/silverwood-core/src/doc.rs index 989ec11..95f2edc 100644 --- a/experimental/silverwood/crates/silverwood-core/src/doc.rs +++ b/experimental/silverwood/crates/silverwood-core/src/doc.rs @@ -41,7 +41,7 @@ use crate::error::{Error, Result}; use crate::id::WorkstreamId; use crate::migrate; use crate::workstream::{ - AgentKind, AgentSession, CheckoutMode, CheckoutState, Location, LocationWithinForest, + AgentSession, CheckoutMode, CheckoutState, Location, LocationWithinForest, SessionKind, SessionLock, Status, Workstream, WorkstreamBody, WorkstreamKind, BASIC_KIND, SESSION_NS, }; @@ -214,7 +214,7 @@ pub(crate) fn unset_kv(doc: &LoroDoc, namespace: &str, key: &str) -> Result<()> pub(crate) fn create_session( doc: &LoroDoc, session_id: &str, - agent_kind: AgentKind, + agent_kind: SessionKind, name: &str, created_at: &str, ) -> Result<()> { @@ -291,9 +291,10 @@ pub(crate) fn set_session_lock( let mut session: AgentSession = serde_json::from_str(&existing) .map_err(|e| Error::Corrupt(format!("session {session_id}: {e}")))?; match &mut session.kind { - AgentKind::ClaudeCode { lock: slot } => *slot = lock, - // A plain shell has no lock slot; refuse rather than silently drop the request. - AgentKind::PlainShell {} => { + SessionKind::ClaudeCode { lock: slot } + | SessionKind::ClaudeCodeNoninteractive { lock: slot, .. } => *slot = lock, + // A shell kind has no lock slot; refuse rather than silently drop the request. + SessionKind::PlainShell {} | SessionKind::DiskSpace {} => { return Err(Error::SessionNotLockable { session_id: session_id.to_string(), kind: session.kind.tag().to_string(), @@ -404,7 +405,7 @@ mod tests { use super::*; use crate::id::{ForestId, WorkstreamId}; - use crate::workstream::{AgentKind, AgentSession, CheckoutMode, WorkstreamKind}; + use crate::workstream::{AgentSession, CheckoutMode, SessionKind, WorkstreamKind}; fn sample_body() -> WorkstreamBody { WorkstreamBody { @@ -438,7 +439,7 @@ mod tests { body.kv.entry(SESSION_NS.into()).or_default().insert( "sid-1".into(), agent_session_json(&AgentSession { - kind: AgentKind::ClaudeCode { lock: None }, + kind: SessionKind::ClaudeCode { lock: None }, name: "planning".into(), created_at: "t0".into(), }), @@ -533,7 +534,7 @@ mod tests { create_session( &a, "sess-A", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "from A", "t0", ) @@ -542,7 +543,7 @@ mod tests { create_session( &b, "sess-B", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "from B", "t0", ) @@ -577,7 +578,7 @@ mod tests { body.kv.entry(SESSION_NS.into()).or_default().insert( "sid-1".into(), agent_session_json(&AgentSession { - kind: AgentKind::ClaudeCode { lock: None }, + kind: SessionKind::ClaudeCode { lock: None }, name: "planning".into(), created_at: "t0".into(), }), @@ -601,7 +602,7 @@ mod tests { assert!(json.get("sessions").is_none()); let encoded = json["kv"][SESSION_NS]["sid-1"].as_str().unwrap(); let session: AgentSession = serde_json::from_str(encoded).unwrap(); - assert_eq!(session.kind, AgentKind::ClaudeCode { lock: None }); + assert_eq!(session.kind, SessionKind::ClaudeCode { lock: None }); assert_eq!(session.name, "planning"); } @@ -611,10 +612,10 @@ mod tests { #[test] fn plain_shell_session_roundtrip() { let doc = build(1, &sample_body()).unwrap(); - create_session(&doc, "sh-1", AgentKind::PlainShell {}, "shell", "t0").unwrap(); + create_session(&doc, "sh-1", SessionKind::PlainShell {}, "shell", "t0").unwrap(); let session = get_session(&doc, "sh-1").unwrap().unwrap(); - assert_eq!(session.kind, AgentKind::PlainShell {}); + assert_eq!(session.kind, SessionKind::PlainShell {}); assert_eq!(session.kind.tag(), "plain-shell"); assert_eq!(session.lock(), None); @@ -629,7 +630,7 @@ mod tests { rename_session(&doc, "sh-1", "my shell").unwrap(); let renamed = get_session(&doc, "sh-1").unwrap().unwrap(); assert_eq!(renamed.name, "my shell"); - assert_eq!(renamed.kind, AgentKind::PlainShell {}); + assert_eq!(renamed.kind, SessionKind::PlainShell {}); assert_eq!(renamed.created_at, "t0"); // A plain shell has no lock slot: locking is refused. diff --git a/experimental/silverwood/crates/silverwood-core/src/error.rs b/experimental/silverwood/crates/silverwood-core/src/error.rs index f5d50bc..abea698 100644 --- a/experimental/silverwood/crates/silverwood-core/src/error.rs +++ b/experimental/silverwood/crates/silverwood-core/src/error.rs @@ -78,6 +78,12 @@ pub enum Error { #[error("session {session_id} is a {kind} session, which has no lock")] SessionNotLockable { session_id: String, kind: String }, + /// A session could not be spawned because its workstream has no ready checkout to + /// run in (none materialized, or provisioning is incomplete). Carries the checkout + /// state string (or `none`). + #[error("workstream {id} has no ready checkout to spawn in (checkout state: {state})")] + NotSpawnable { id: WorkstreamId, state: String }, + /// A frontend tried to write a core-reserved kv namespace directly. #[error("namespace {0:?} is reserved for silverwood core; use `silverwood session`")] ReservedNamespace(String), diff --git a/experimental/silverwood/crates/silverwood-core/src/forest.rs b/experimental/silverwood/crates/silverwood-core/src/forest.rs index d4d18a3..ec5e8e7 100644 --- a/experimental/silverwood/crates/silverwood-core/src/forest.rs +++ b/experimental/silverwood/crates/silverwood-core/src/forest.rs @@ -17,9 +17,13 @@ use crate::error::{Error, Result}; use crate::id::{ForestId, WorkstreamId}; use crate::migrate; use crate::provider::{CheckoutProvider, JjColocated}; +use crate::spawn::{ + claude_code_noninteractive_plan, claude_code_plan, disk_space_plan, plain_shell_plan, + ClaudeRun, ShellPlan, SpawnSeed, +}; use crate::workstream::{ - AgentKind, CheckoutExtent, CheckoutMode, CheckoutState, DoctorReport, Location, - LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SessionLock, Status, Workstream, + CheckoutExtent, CheckoutMode, CheckoutState, DoctorReport, Location, LocationWithinForest, + NewCheckoutMode, NewKind, NewWorkstream, SessionKind, SessionLock, Status, Workstream, WorkstreamBody, WorkstreamKind, RESERVED_NS_PREFIX, }; @@ -343,7 +347,7 @@ impl Forest { &self, id: WorkstreamId, session_id: &str, - agent_kind: AgentKind, + agent_kind: SessionKind, name: &str, ) -> Result<()> { let doc = self.load_doc(id)?; @@ -373,7 +377,7 @@ impl Forest { /// [`Error::SessionNotFound`] if the session is absent. /// /// The match over the session kind is deliberately exhaustive: adding an - /// [`AgentKind`] forces a decision here about how (or whether) doctor checks it, + /// [`SessionKind`] forces a decision here about how (or whether) doctor checks it, /// rather than silently reporting `conversation_exists: None`. pub fn doctor_session( &self, @@ -385,12 +389,12 @@ impl Forest { let session = doc::get_session(&doc, session_id)? .ok_or_else(|| Error::SessionNotFound(session_id.to_string()))?; let conversation_exists = match &session.kind { - AgentKind::ClaudeCode { .. } => Some(crate::claude::claude_conversation_exists( - config_dir, session_id, - )), - // A plain shell has no persisted conversation to check; doctor can't + SessionKind::ClaudeCode { .. } | SessionKind::ClaudeCodeNoninteractive { .. } => Some( + crate::claude::claude_conversation_exists(config_dir, session_id), + ), + // A shell kind has no persisted conversation to check; doctor can't // judge whether it's safe to remove, so report `None`. - AgentKind::PlainShell {} => None, + SessionKind::PlainShell {} | SessionKind::DiskSpace {} => None, }; Ok(DoctorReport { workstream_id: id.to_string(), @@ -400,6 +404,73 @@ impl Forest { }) } + /// Build the [`ShellPlan`] for running a durable session (`silverwood spawn from-id`). + /// A session records *how to run itself*: this reads its [`SessionKind`] and materializes + /// the matching command in the workstream's ready checkout, so a frontend never re-derives + /// the command. For a claude kind, first-run vs resume is chosen from whether Claude's + /// transcript exists on disk under `claude_config_dir` (so a never-prompted session starts + /// fresh rather than failing a resume). `seed`/`claude_config_dir` are frontend policy + /// (env/passwd), supplied by the caller — mirroring [`Forest::doctor_session`]. + /// + /// Errors [`Error::NotSpawnable`] if there is no ready checkout, or + /// [`Error::SessionNotFound`] if the session is absent. + pub fn spawn_plan_from_session( + &self, + id: WorkstreamId, + session_id: &str, + seed: &SpawnSeed, + claude_config_dir: &Path, + ) -> Result { + let ws = self.get(id)?; + let not_spawnable = |state: &str| Error::NotSpawnable { + id, + state: state.to_string(), + }; + let mode = ws.body.mode().ok_or_else(|| not_spawnable("none"))?; + if mode.state() != CheckoutState::Ready { + return Err(not_spawnable(mode.state().as_str())); + } + let cwd = match ws.body.location().map(|loc| &loc.within) { + Some(LocationWithinForest::BasicForest { path }) => path.clone(), + // A ready checkout without a location would be a corrupt document. + None => { + return Err(Error::Corrupt(format!( + "workstream {id} has no checkout location" + ))) + } + }; + + let session = doc::get_session(&self.load_doc(id)?, session_id)? + .ok_or_else(|| Error::SessionNotFound(session_id.to_string()))?; + + // First-run vs resume from ground truth: does Claude's transcript exist on disk? + let claude_run = || { + if crate::claude::claude_conversation_exists(claude_config_dir, session_id) { + ClaudeRun::Resume + } else { + ClaudeRun::FirstRun + } + }; + + let plan = match session.kind { + SessionKind::ClaudeCode { .. } => { + claude_code_plan(&cwd, session_id, claude_run(), seed) + } + SessionKind::ClaudeCodeNoninteractive { + run_direnv_exec, .. + } => claude_code_noninteractive_plan( + &cwd, + session_id, + claude_run(), + run_direnv_exec, + seed, + ), + SessionKind::PlainShell {} => plain_shell_plan(&cwd, seed), + SessionKind::DiskSpace {} => disk_space_plan(&cwd, seed), + }; + Ok(plan) + } + /// Acquire the best-effort advisory lock on a session for `holder`. Succeeds /// if the session is unlocked or already held by `holder` (refreshing /// `acquired_at`). If held by someone else, errors [`Error::SessionLocked`] diff --git a/experimental/silverwood/crates/silverwood-core/src/lib.rs b/experimental/silverwood/crates/silverwood-core/src/lib.rs index 3c98b1b..6cae822 100644 --- a/experimental/silverwood/crates/silverwood-core/src/lib.rs +++ b/experimental/silverwood/crates/silverwood-core/src/lib.rs @@ -36,9 +36,12 @@ pub use id::{ForestId, IdScheme, ParseWorkstreamIdError, WorkstreamId}; pub use migrate::DOC_SCHEMA_VERSION; pub use provider::{CheckoutProvider, JjColocated}; pub use source::{AbsolutePath, HttpsGitUrl}; -pub use spawn::{agent_shell_plan, base_shell_plan, ShellPlan, SpawnSeed}; +pub use spawn::{ + claude_code_noninteractive_plan, claude_code_plan, disk_space_plan, plain_shell_plan, + ClaudeRun, ShellPlan, SpawnSeed, +}; pub use workstream::{ - AgentKind, AgentSession, CheckoutExtent, CheckoutMode, CheckoutState, DoctorReport, Location, - LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SessionLock, Status, Workstream, - WorkstreamBody, WorkstreamKind, + AgentSession, CheckoutExtent, CheckoutMode, CheckoutState, DoctorReport, Location, + LocationWithinForest, NewCheckoutMode, NewKind, NewWorkstream, SessionKind, SessionLock, + Status, Workstream, WorkstreamBody, WorkstreamKind, }; diff --git a/experimental/silverwood/crates/silverwood-core/src/spawn.rs b/experimental/silverwood/crates/silverwood-core/src/spawn.rs index 347d6e8..02aca97 100644 --- a/experimental/silverwood/crates/silverwood-core/src/spawn.rs +++ b/experimental/silverwood/crates/silverwood-core/src/spawn.rs @@ -1,31 +1,32 @@ -//! Interactive-shell spawning — the hard-coded variants for creating an agent -//! shell in a checkout. silverwood owns *how* an interactive shell is created -//! (both the environment scrub and the per-checkout-mode command), so a frontend -//! (papyrus) just runs `silverwood spawn` instead of reconstructing any of it. +//! Session spawning — how silverwood runs a session's shell in a checkout. silverwood +//! owns *how* a shell is created (the environment scrub and the per-kind command), so a +//! frontend (papyrus) just runs `silverwood spawn` instead of reconstructing any of it. //! -//! Two variants, both fixed in code (never data — a data-driven command would be -//! an arbitrary-code-execution surface): -//! 1. a **base shell** — a clean login shell that does NOT inherit the parent -//! process's environment, and -//! 2. an **agent** — `claude` run inside that clean env, wrapped in -//! `direnv exec ` for a direnv-unsafe checkout mode so it loads the -//! checkout's pre-approved `.envrc`. +//! Every kind runs in a clean, non-inherited login environment (see `clean_env`): a +//! frontend launched from a nix devshell has a polluted `process.env` (`IN_NIX_SHELL`, +//! `DEVENV_*`, an augmented `PATH`, an overwritten `$SHELL`), so a spawned shell must not +//! inherit it. The user's real login environment is reconstructed by running the login +//! shell from an empty base in a dir with no `.envrc`, and the vars a login capture can't +//! produce are overlaid on top (`TERM`, the ssh-agent socket, `SHELL`, a default `LANG`) — +//! see `overlay_session_env`. The login shell comes from the passwd database (via the +//! frontend's [`SpawnSeed`]), not the polluted `$SHELL`. //! -//! The env scrub mirrors what papyrus used to do inline: `process.env` in a -//! frontend launched from a nix devshell is polluted (`IN_NIX_SHELL`, `DEVENV_*`, -//! an augmented `PATH`, an overwritten `$SHELL`), so a spawned agent must not inherit -//! it. Instead the user's real login environment is reconstructed by running the login -//! shell from an empty base in a dir with no `.envrc`, and the vars a login capture -//! can't produce are overlaid on top (`TERM`, the ssh-agent socket, `SHELL`, a default -//! `LANG`) — see `overlay_session_env`. The login shell itself comes from the passwd -//! database (via the frontend's `SpawnSeed`), not the polluted `$SHELL`. +//! silverwood is deliberately **direnv-blind** for the interactive kinds. Rather than +//! wrapping `claude` in `direnv exec`, the interactive kinds (`claude-code`, `disk-space`) +//! run the user's real login-interactive shell and *synchronously invoke its own prompt +//! hooks* — whatever the user installed (direnv, git-prompt, …) — before chaining the +//! command with `exec`. So a checkout's `.envrc` loads (or is ignored) exactly as it would +//! when the user types the command at their own prompt, and a user with no direnv hook +//! gets none. A non-interactive ` -c 'cmd'` cannot do this (its interactive rc is +//! not sourced and no prompt is drawn), which is why these kinds use ` -l -i -c` and +//! run the hooks by hand — see `interactive_shell_plan`/`prompt_hook_snippet`. The one +//! kind that still wraps `direnv exec` explicitly is `claude-code-noninteractive`, gated on +//! its own `run_direnv_exec` flag (the deterministic, rc-free counterpart). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::workstream::CheckoutMode; - /// The dynamic, machine/session inputs a spawn needs, gathered by the frontend. /// Resolving these (identity from the passwd database, session context from the /// environment) is frontend policy, so core takes them explicitly rather than touching @@ -79,9 +80,30 @@ impl ShellPlan { } } -/// The **base shell** variant: an interactive login shell (` -l`) in `cwd` -/// with a clean, non-inherited environment. -pub fn base_shell_plan(cwd: &str, seed: &SpawnSeed) -> ShellPlan { +/// Whether a claude-code session is starting for the first time (`--session-id`, which +/// creates the conversation) or resuming an existing one (`--resume`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaudeRun { + /// First launch of this session id — `claude --session-id `. + FirstRun, + /// Reconnect to an existing conversation — `claude --resume `. + Resume, +} + +impl ClaudeRun { + /// The `claude` flag that selects this run mode. + fn flag(self) -> &'static str { + match self { + ClaudeRun::FirstRun => "--session-id", + ClaudeRun::Resume => "--resume", + } + } +} + +/// The **plain-shell** kind: an interactive login shell (` -l`) in `cwd` with a +/// clean, non-inherited environment. Attached to a tty it reaches a real prompt, so the +/// user's own prompt hooks (direnv, …) fire naturally — no synchronous hook run needed. +pub fn plain_shell_plan(cwd: &str, seed: &SpawnSeed) -> ShellPlan { ShellPlan { program: seed.shell.clone(), args: vec!["-l".to_string()], @@ -90,17 +112,55 @@ pub fn base_shell_plan(cwd: &str, seed: &SpawnSeed) -> ShellPlan { } } -/// The **agent** variant: `claude` (fresh `--session-id`, or `--resume` to -/// reconnect), wrapped in `direnv exec ` for a direnv-unsafe checkout mode, -/// in a clean, non-inherited environment. -pub fn agent_shell_plan( - mode: &CheckoutMode, +/// The **claude-code** kind: `claude` run as a thin wrapper over the user's +/// login-interactive shell (so their own direnv/rc setup applies), started fresh +/// (`FirstRun`) or resumed (`Resume`). See `interactive_shell_plan`. +pub fn claude_code_plan( cwd: &str, session_id: &str, - resume: bool, + run: ClaudeRun, seed: &SpawnSeed, ) -> ShellPlan { - let mut argv = agent_argv(mode, cwd, session_id, resume); + // `session_id` is single-quoted: it is interpolated into the shell `-c` script (a + // UUID in practice, but quote defensively). The flag is a fixed literal. + interactive_shell_plan( + cwd, + &format!("exec claude {} '{session_id}'", run.flag()), + seed, + ) +} + +/// The **disk-space** kind: a `df` refresh loop, run through the same interactive-shell +/// mechanism as [`plain_shell_plan`] (so it is not special-cased). The loop is POSIX `sh`, +/// not the login shell — the login shell's syntax may differ (e.g. fish). +pub fn disk_space_plan(cwd: &str, seed: &SpawnSeed) -> ShellPlan { + interactive_shell_plan( + cwd, + "exec sh -c 'while true; do clear; df -h; sleep 1; done'", + seed, + ) +} + +/// The **claude-code-noninteractive** kind: `claude` run directly in the clean login env +/// (no interactive shell), optionally wrapped in `direnv exec ` to load the checkout's +/// pre-approved `.envrc`. The explicit, deterministic counterpart to [`claude_code_plan`] — +/// `run_direnv_exec` selects the wrapping, not the checkout mode. `cwd` is a distinct argv +/// element, so no shell quoting is needed (the plan is exec'd directly, never via a shell). +pub fn claude_code_noninteractive_plan( + cwd: &str, + session_id: &str, + run: ClaudeRun, + run_direnv_exec: bool, + seed: &SpawnSeed, +) -> ShellPlan { + let claude = ["claude", run.flag(), session_id].map(String::from); + let mut argv: Vec = if run_direnv_exec { + let mut v = vec!["direnv".to_string(), "exec".to_string(), cwd.to_string()]; + v.extend(claude); + v + } else { + claude.to_vec() + }; let program = argv.remove(0); ShellPlan { program, @@ -110,27 +170,63 @@ pub fn agent_shell_plan( } } -/// The agent command argv, selected from the checkout mode. Hard-coded per mode -/// (no data-driven templates): the direnv-unsafe modes run claude under -/// `direnv exec ` (their `.envrc` was pre-approved at clone time); every other -/// mode runs claude directly. `cwd` is passed as its own argv element, so no shell -/// quoting is needed (the plan is exec'd directly, never through a shell). -fn agent_argv(mode: &CheckoutMode, cwd: &str, session_id: &str, resume: bool) -> Vec { - let flag = if resume { "--resume" } else { "--session-id" }; - let claude = ["claude", flag, session_id].map(String::from); - match mode { - CheckoutMode::JjColocatedDirenvUnsafe { .. } | CheckoutMode::ApfsCowDirenvUnsafe { .. } => { - let mut argv = vec!["direnv".to_string(), "exec".to_string(), cwd.to_string()]; - argv.extend(claude); - argv - } - _ => claude.to_vec(), +/// Run `chained` inside the user's login-interactive shell (` -l -i -c`), after +/// synchronously invoking the shell's own prompt hooks so `chained` inherits their +/// environment (direnv, etc.). `chained` should `exec` its target so the shell is replaced +/// (the PTY then tracks the target directly). The hook-running snippet is chosen from the +/// login shell's basename; an unknown shell runs no hooks — the same environment a user +/// with none installed would get. +fn interactive_shell_plan(cwd: &str, chained: &str, seed: &SpawnSeed) -> ShellPlan { + let prefix = prompt_hook_snippet(&seed.shell) + .map(|s| format!("{s}\n")) + .unwrap_or_default(); + ShellPlan { + program: seed.shell.clone(), + args: vec![ + "-l".to_string(), + "-i".to_string(), + "-c".to_string(), + format!("{prefix}{chained}"), + ], + cwd: cwd.to_string(), + env: clean_env(seed), } } +/// The inline command that runs the login shell's own prompt hooks once — whatever the +/// user installed (direnv, git-prompt, …) — so a command chained after it inherits their +/// environment. Keyed on the login shell's basename, because the shells' syntaxes are +/// mutually unparseable (fish especially), so the branch is chosen before launch. `None` +/// for a shell we do not know how to drive: it degrades to running no hooks (the same +/// environment a user with none installed gets). silverwood names no specific hook (e.g. +/// direnv) here — it just runs whatever the shell registered. +fn prompt_hook_snippet(shell: &str) -> Option { + let name = Path::new(shell).file_name()?.to_str()?; + let snippet = match name { + // zsh keeps prompt hooks in the `precmd_functions` array (+ an optional `precmd`). + "zsh" => "for f in $precmd_functions; do $f; done; (( $+functions[precmd] )) && precmd", + // fish exposes a first-class \"run the prompt hooks\": emit the event. + "fish" => "emit fish_prompt", + // bash: a login shell reads ~/.bash_profile, never ~/.bashrc — see BASH_PROMPT_HOOK. + "bash" => BASH_PROMPT_HOOK, + _ => return None, + }; + Some(snippet.to_string()) +} + +/// bash prompt-hook runner (see `prompt_hook_snippet`). A bash login shell sources +/// `~/.bash_profile`, never `~/.bashrc`, so if the profile did not source it the direnv +/// hook was never installed — the guard pulls `~/.bashrc` in *only* when no prompt hook is +/// registered (a no-op in the normal case, so no double-source). It then runs +/// `PROMPT_COMMAND` (a string pre-5.1, an array in 5.1+) and any `precmd_functions` +/// (bash-preexec). Works back to bash 3.2 (the array branch simply never matches there). +const BASH_PROMPT_HOOK: &str = r#"if [[ -z "$(declare -p PROMPT_COMMAND 2>/dev/null)" && ${#precmd_functions[@]} -eq 0 ]]; then [[ -r ~/.bashrc ]] && . ~/.bashrc; fi +if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then for c in "${PROMPT_COMMAND[@]}"; do eval "$c"; done; else [[ -n "${PROMPT_COMMAND:-}" ]] && eval "${PROMPT_COMMAND}"; fi +for f in "${precmd_functions[@]}"; do "$f"; done"#; + /// The scrubbed environment for a spawned shell: the captured login env (or a /// reconstructed fallback), with the session/identity vars a login capture can't -/// produce overlaid on top (see [`overlay_session_env`]). +/// produce overlaid on top (see `overlay_session_env`). fn clean_env(seed: &SpawnSeed) -> BTreeMap { let mut env = capture_login_env(seed).unwrap_or_else(|| fallback_env(seed)); overlay_session_env(&mut env, seed); @@ -237,80 +333,115 @@ fn is_executable(path: &Path) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::workstream::CheckoutState; - fn jj_colocated() -> CheckoutMode { - CheckoutMode::JjColocated { - initial_source: "https://example.com/x.git".into(), - state: CheckoutState::Ready, + fn seed_with_shell(shell: &str) -> SpawnSeed { + SpawnSeed { + home: "/home/x".to_string(), + user: Some("x".to_string()), + shell: shell.to_string(), + term: Some("xterm-256color".to_string()), + ssh_auth_sock: Some("/tmp/agent.sock".to_string()), } } - fn direnv_unsafe() -> CheckoutMode { - CheckoutMode::JjColocatedDirenvUnsafe { - initial_source: "https://example.com/x.git".into(), - state: CheckoutState::Ready, - } + #[test] + fn plain_shell_is_a_login_shell() { + let plan = plain_shell_plan("/w/abc", &seed_with_shell("/bin/zsh")); + assert_eq!(plan.program, "/bin/zsh"); + assert_eq!(plan.args, vec!["-l"]); + assert_eq!(plan.cwd, "/w/abc"); } - fn apfs_cow_direnv_unsafe() -> CheckoutMode { - CheckoutMode::ApfsCowDirenvUnsafe { - initial_source: "/Users/x/repo".into(), - state: CheckoutState::Ready, - } + #[test] + fn claude_code_runs_inside_the_login_interactive_shell() { + let plan = claude_code_plan( + "/w/abc", + "sess-1", + ClaudeRun::FirstRun, + &seed_with_shell("/bin/zsh"), + ); + assert_eq!(plan.program, "/bin/zsh"); + assert_eq!(plan.args[0..3], ["-l", "-i", "-c"]); + let script = &plan.args[3]; + assert!( + script.contains("exec claude --session-id 'sess-1'"), + "script: {script}" + ); + // Runs the shell's own prompt hooks (zsh: precmd_functions) — never `direnv exec`. + assert!(script.contains("precmd_functions"), "script: {script}"); + assert!(!script.contains("direnv exec"), "script: {script}"); } #[test] - fn plain_mode_runs_claude_directly() { - assert_eq!( - agent_argv(&jj_colocated(), "/w/abc", "sess-1", false), - vec!["claude", "--session-id", "sess-1"] + fn claude_code_resume_uses_the_resume_flag() { + let plan = claude_code_plan( + "/w/abc", + "sess-1", + ClaudeRun::Resume, + &seed_with_shell("/bin/bash"), + ); + assert!( + plan.args[3].contains("exec claude --resume 'sess-1'"), + "script: {}", + plan.args[3] ); } #[test] - fn resume_flips_the_claude_flag() { - assert_eq!( - agent_argv(&jj_colocated(), "/w/abc", "sess-1", true), - vec!["claude", "--resume", "sess-1"] + fn disk_space_runs_a_df_loop_via_the_interactive_shell() { + let plan = disk_space_plan("/w/abc", &seed_with_shell("/usr/bin/fish")); + assert_eq!(plan.program, "/usr/bin/fish"); + assert_eq!(plan.args[0..3], ["-l", "-i", "-c"]); + let script = &plan.args[3]; + assert!(script.contains("emit fish_prompt"), "script: {script}"); // fish's hook runner + assert!(script.contains("df -h"), "script: {script}"); + } + + #[test] + fn noninteractive_runs_claude_directly_when_direnv_off() { + let plan = claude_code_noninteractive_plan( + "/w/abc", + "sess-1", + ClaudeRun::FirstRun, + false, + &seed_with_shell("/bin/zsh"), ); + assert_eq!(plan.program, "claude"); + assert_eq!(plan.args, vec!["--session-id", "sess-1"]); } #[test] - fn direnv_unsafe_mode_wraps_claude_in_direnv_exec() { + fn noninteractive_wraps_claude_in_direnv_exec_when_on() { // The checkout path is a distinct argv element (no shell quoting needed). - assert_eq!( - agent_argv(&direnv_unsafe(), "/w/a b/c", "sess-1", false), - vec![ - "direnv", - "exec", - "/w/a b/c", - "claude", - "--session-id", - "sess-1" - ] + let plan = claude_code_noninteractive_plan( + "/w/a b/c", + "sess-1", + ClaudeRun::Resume, + true, + &seed_with_shell("/bin/zsh"), ); + assert_eq!(plan.program, "direnv"); assert_eq!( - agent_argv(&direnv_unsafe(), "/w/abc", "sess-1", true), - vec!["direnv", "exec", "/w/abc", "claude", "--resume", "sess-1"] + plan.args, + vec!["exec", "/w/a b/c", "claude", "--resume", "sess-1"] ); } #[test] - fn apfs_cow_direnv_unsafe_mode_wraps_claude_in_direnv_exec() { - // The apfs-cow direnv-unsafe mode also pre-approves its `.envrc`, so it runs - // claude under `direnv exec` just like the jj direnv-unsafe mode. + fn prompt_hook_snippet_is_chosen_by_shell_basename() { + assert!(prompt_hook_snippet("/bin/zsh") + .unwrap() + .contains("precmd_functions")); assert_eq!( - agent_argv(&apfs_cow_direnv_unsafe(), "/w/abc", "sess-1", false), - vec![ - "direnv", - "exec", - "/w/abc", - "claude", - "--session-id", - "sess-1" - ] + prompt_hook_snippet("/opt/homebrew/bin/fish").unwrap(), + "emit fish_prompt" ); + // bash guards that ~/.bashrc loads even if the login profile didn't source it. + let bash = prompt_hook_snippet("/bin/bash").unwrap(); + assert!(bash.contains(".bashrc"), "bash: {bash}"); + assert!(bash.contains("PROMPT_COMMAND"), "bash: {bash}"); + // An unknown shell degrades to no hooks. + assert!(prompt_hook_snippet("/usr/bin/nu").is_none()); } #[test] @@ -346,16 +477,6 @@ mod tests { assert_eq!(plan.resolve_program(), Some(PathBuf::from("/bin/zsh"))); } - fn seed_with_shell(shell: &str) -> SpawnSeed { - SpawnSeed { - home: "/home/x".to_string(), - user: Some("x".to_string()), - shell: shell.to_string(), - term: Some("xterm-256color".to_string()), - ssh_auth_sock: Some("/tmp/agent.sock".to_string()), - } - } - #[test] fn overlay_sets_shell_authoritatively() { // A stale SHELL in the captured env must lose to the resolved login shell. diff --git a/experimental/silverwood/crates/silverwood-core/src/tests/convergence.rs b/experimental/silverwood/crates/silverwood-core/src/tests/convergence.rs index d0590d7..db1ef99 100644 --- a/experimental/silverwood/crates/silverwood-core/src/tests/convergence.rs +++ b/experimental/silverwood/crates/silverwood-core/src/tests/convergence.rs @@ -13,7 +13,7 @@ use proptest::prelude::*; use super::{any_id, sample_bodies}; use crate::doc; -use crate::workstream::AgentKind; +use crate::workstream::SessionKind; /// Per-forest ops: kv entries `(key index, value index)` and session indices. /// Namespacing by forest index (below) keeps every forest's writes disjoint, so @@ -81,7 +81,7 @@ proptest! { doc::create_session( &d, &format!("f{i}-s{si}"), - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "n", "t0", ) diff --git a/experimental/silverwood/crates/silverwood-core/src/tests/mod.rs b/experimental/silverwood/crates/silverwood-core/src/tests/mod.rs index 62636e7..9c78765 100644 --- a/experimental/silverwood/crates/silverwood-core/src/tests/mod.rs +++ b/experimental/silverwood/crates/silverwood-core/src/tests/mod.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::id::ForestId; use crate::workstream::{ - AgentKind, AgentSession, CheckoutMode, CheckoutState, Location, LocationWithinForest, Status, + AgentSession, CheckoutMode, CheckoutState, Location, LocationWithinForest, SessionKind, Status, WorkstreamBody, WorkstreamKind, SESSION_NS, }; @@ -126,7 +126,7 @@ fn basic( } for (id, nm) in sessions { let session = AgentSession { - kind: AgentKind::ClaudeCode { lock: None }, + kind: SessionKind::ClaudeCode { lock: None }, name: nm.to_string(), created_at: "1970-01-01T00:00:00Z".to_string(), }; diff --git a/experimental/silverwood/crates/silverwood-core/src/workstream.rs b/experimental/silverwood/crates/silverwood-core/src/workstream.rs index 3c36a1a..10e5303 100644 --- a/experimental/silverwood/crates/silverwood-core/src/workstream.rs +++ b/experimental/silverwood/crates/silverwood-core/src/workstream.rs @@ -110,34 +110,56 @@ pub struct SessionLock { pub acquired_at: String, } -/// Which agent (or shell) an [`AgentSession`] belongs to, carrying that kind's own -/// state. Open, internally-tagged enum. The claude-code kind carries an optional -/// [`SessionLock`] (a Claude Code session can be resumed by only one client at a -/// time); a kind that already guarantees single-user access — such as `plain-shell`, -/// whose every reopen is an independent fresh login shell — carries no lock. +/// Which kind of session an [`AgentSession`] is (agent or shell), carrying that +/// kind's own state. Open, internally-tagged enum. The claude-code kinds carry an +/// optional [`SessionLock`] (a Claude Code session can be resumed by only one client +/// at a time); a kind that already guarantees single-user access — such as +/// `plain-shell`, whose every reopen is an independent fresh login shell — carries no +/// lock. A session records *how to run itself*, so `silverwood spawn from-id` can +/// materialize it without the frontend re-deriving the command. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] #[non_exhaustive] -pub enum AgentKind { - /// A Claude Code session, with its best-effort resumption lock (if held). +pub enum SessionKind { + /// A Claude Code session run as a thin wrapper over the user's login-interactive + /// shell (so the user's own direnv/rc setup applies), with its resumption lock + /// (if held). ClaudeCode { /// The advisory resumption lock, if currently held. #[serde(default, skip_serializing_if = "Option::is_none")] lock: Option, }, + /// A Claude Code session run non-interactively — `claude` directly, rather than + /// inside the user's interactive shell — with `direnv exec ` wrapping chosen + /// explicitly by `run_direnv_exec` (not derived from the checkout mode). Carries a + /// resumption lock like [`SessionKind::ClaudeCode`]. + ClaudeCodeNoninteractive { + /// The advisory resumption lock, if currently held. + #[serde(default, skip_serializing_if = "Option::is_none")] + lock: Option, + /// Whether to run `claude` under `direnv exec ` to load the checkout's + /// pre-approved `.envrc`. + run_direnv_exec: bool, + }, /// A plain login shell recorded so its tab (name) persists. It has no durable /// process to resume — reopening spawns a fresh login shell — so it carries no /// lock and no conversation for doctor to check. Named to leave room for other /// shell kinds later. PlainShell {}, + /// A disk-space monitor: a `df` loop run through the same interactive-shell + /// mechanism as [`SessionKind::PlainShell`]. An example of a non-agent session + /// kind that exercises the algebraic spawn path. + DiskSpace {}, } -impl AgentKind { +impl SessionKind { /// The stored `kind` discriminant for this variant (matches its serde tag). pub fn tag(&self) -> &'static str { match self { - AgentKind::ClaudeCode { .. } => "claude-code", - AgentKind::PlainShell {} => "plain-shell", + SessionKind::ClaudeCode { .. } => "claude-code", + SessionKind::ClaudeCodeNoninteractive { .. } => "claude-code-noninteractive", + SessionKind::PlainShell {} => "plain-shell", + SessionKind::DiskSpace {} => "disk-space", } } } @@ -269,7 +291,7 @@ impl LocationWithinForest { pub struct AgentSession { /// Which agent this session belongs to, plus that kind's own state. #[serde(flatten)] - pub kind: AgentKind, + pub kind: SessionKind, /// Human-friendly name for the session. pub name: String, /// When the association was created (RFC3339). @@ -281,8 +303,9 @@ impl AgentSession { /// currently held. pub fn lock(&self) -> Option<&SessionLock> { match &self.kind { - AgentKind::ClaudeCode { lock } => lock.as_ref(), - AgentKind::PlainShell {} => None, + SessionKind::ClaudeCode { lock } + | SessionKind::ClaudeCodeNoninteractive { lock, .. } => lock.as_ref(), + SessionKind::PlainShell {} | SessionKind::DiskSpace {} => None, } } } diff --git a/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs b/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs index 946fe7b..0a500e5 100644 --- a/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs +++ b/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs @@ -4,7 +4,7 @@ mod common; use common::{new_ws, temp_forest, FakeOk}; -use silverwood_core::{AgentKind, Forest}; +use silverwood_core::{Forest, LocationWithinForest, SessionKind, SpawnSeed}; #[test] fn kv_round_trip() { @@ -66,7 +66,7 @@ fn reserved_namespace_is_rejected() { // Sessions go through the typed API and land in the reserved namespace. forest - .create_session(ws.id, "s1", AgentKind::ClaudeCode { lock: None }, "n") + .create_session(ws.id, "s1", SessionKind::ClaudeCode { lock: None }, "n") .unwrap(); assert_eq!(forest.get(ws.id).unwrap().body.sessions().len(), 1); @@ -83,7 +83,7 @@ fn session_lifecycle() { .create_session( ws.id, "abc-123", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "planning", ) .unwrap(); @@ -92,7 +92,7 @@ fn session_lifecycle() { .create_session( ws.id, "abc-123", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "dup" ) .is_err(), @@ -106,7 +106,7 @@ fn session_lifecycle() { let sessions = got.body.sessions(); let session = &sessions["abc-123"]; assert_eq!(session.name, "planning v2"); - assert_eq!(session.kind, AgentKind::ClaudeCode { lock: None }); + assert_eq!(session.kind, SessionKind::ClaudeCode { lock: None }); assert!(!session.created_at.is_empty()); assert!( @@ -132,7 +132,7 @@ fn session_doctor_reports_variant_and_conversation_presence() { .create_session( ws.id, "sess-1", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "planning", ) .unwrap(); @@ -165,6 +165,143 @@ fn session_doctor_reports_variant_and_conversation_presence() { let _ = std::fs::remove_dir_all(&claude); } +#[test] +fn session_doctor_and_lock_cover_the_new_kinds() { + let dir = temp_forest("new-kinds"); + let forest = Forest::open_with_provider(&dir, Box::new(FakeOk)).unwrap(); + let ws = forest.create_workstream(new_ws("new-kinds-demo")).unwrap(); + let claude = temp_forest("new-kinds-claude"); + + // claude-code-noninteractive is a claude kind: doctor checks the transcript, and it locks. + forest + .create_session( + ws.id, + "ni", + SessionKind::ClaudeCodeNoninteractive { + lock: None, + run_direnv_exec: true, + }, + "ni", + ) + .unwrap(); + let report = forest.doctor_session(ws.id, "ni", &claude).unwrap(); + assert_eq!(report.kind, "claude-code-noninteractive"); + assert_eq!(report.conversation_exists, Some(false)); + forest.lock_session(ws.id, "ni", "A", false).unwrap(); + assert_eq!( + forest.get(ws.id).unwrap().body.sessions()["ni"] + .lock() + .unwrap() + .holder, + "A" + ); + + // disk-space is a shell kind: no conversation to check, and it is not lockable. + forest + .create_session(ws.id, "ds", SessionKind::DiskSpace {}, "ds") + .unwrap(); + let report = forest.doctor_session(ws.id, "ds", &claude).unwrap(); + assert_eq!(report.kind, "disk-space"); + assert_eq!(report.conversation_exists, None); + assert!(forest.lock_session(ws.id, "ds", "A", false).is_err()); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&claude); +} + +#[test] +fn spawn_plan_from_session_resolves_each_kind() { + let dir = temp_forest("spawn-from-id"); + let forest = Forest::open_with_provider(&dir, Box::new(FakeOk)).unwrap(); + let ws = forest.create_workstream(new_ws("spawn-demo")).unwrap(); + let LocationWithinForest::BasicForest { path: cwd } = + ws.body.location().unwrap().within.clone() + else { + unreachable!("a basic workstream has a basic-forest location"); + }; + let claude = temp_forest("spawn-claude"); // empty ⇒ no transcript ⇒ first-run + + let seed = SpawnSeed { + home: "/home/x".into(), + user: Some("x".into()), + shell: "/bin/zsh".into(), + term: None, + ssh_auth_sock: None, + }; + + // claude-code (interactive): runs inside the login-interactive shell, first-run. + forest + .create_session(ws.id, "cc-1", SessionKind::ClaudeCode { lock: None }, "cc") + .unwrap(); + let plan = forest + .spawn_plan_from_session(ws.id, "cc-1", &seed, &claude) + .unwrap(); + assert_eq!(plan.program, "/bin/zsh"); + assert_eq!(plan.args[0..3], ["-l", "-i", "-c"]); + assert!(plan.args[3].contains("exec claude --session-id 'cc-1'")); + assert_eq!(plan.cwd, cwd); + + // claude-code-noninteractive with direnv on: `direnv exec claude --session-id`. + forest + .create_session( + ws.id, + "ni-1", + SessionKind::ClaudeCodeNoninteractive { + lock: None, + run_direnv_exec: true, + }, + "ni", + ) + .unwrap(); + let plan = forest + .spawn_plan_from_session(ws.id, "ni-1", &seed, &claude) + .unwrap(); + assert_eq!(plan.program, "direnv"); + assert_eq!( + plan.args, + vec!["exec", &cwd, "claude", "--session-id", "ni-1"] + ); + + // plain-shell: a login shell in the checkout. + forest + .create_session(ws.id, "sh-1", SessionKind::PlainShell {}, "sh") + .unwrap(); + let plan = forest + .spawn_plan_from_session(ws.id, "sh-1", &seed, &claude) + .unwrap(); + assert_eq!(plan.program, "/bin/zsh"); + assert_eq!(plan.args, vec!["-l"]); + + // disk-space: a `df` loop via the interactive shell. + forest + .create_session(ws.id, "ds-1", SessionKind::DiskSpace {}, "ds") + .unwrap(); + let plan = forest + .spawn_plan_from_session(ws.id, "ds-1", &seed, &claude) + .unwrap(); + assert!(plan.args[3].contains("df -h")); + + // A transcript on disk flips the claude flag to --resume. + let proj = claude.join("projects").join("-any"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(proj.join("cc-2.jsonl"), "{}\n").unwrap(); + forest + .create_session(ws.id, "cc-2", SessionKind::ClaudeCode { lock: None }, "cc2") + .unwrap(); + let plan = forest + .spawn_plan_from_session(ws.id, "cc-2", &seed, &claude) + .unwrap(); + assert!(plan.args[3].contains("exec claude --resume 'cc-2'")); + + // An absent session errors. + assert!(forest + .spawn_plan_from_session(ws.id, "nope", &seed, &claude) + .is_err()); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&claude); +} + #[test] fn session_lock_lifecycle() { let dir = temp_forest("session-lock"); @@ -174,7 +311,7 @@ fn session_lock_lifecycle() { .create_session( ws.id, "s1", - AgentKind::ClaudeCode { lock: None }, + SessionKind::ClaudeCode { lock: None }, "planning", ) .unwrap(); -- 2.51.2