From bd610bc3daf3be2bfadb2b06ad857abe66ea98b3 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 6 Sep 2026 13:09:05 -0700 Subject: [PATCH] [silverwood] session: rename the noninteractive kind to `claude-code-noninteractiveshell` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the `claude-code-noninteractive` session kind so "noninteractive" clearly refers to how the parent process invokes claude-code (a non-interactive shell), not to claude itself. The Rust variant becomes `ClaudeCodeNoninteractiveshell` (one CamelCase token), so clap's `session create`/`spawn` subcommand and serde's `kind` tag both kebab-derive to `claude-code-noninteractiveshell` with no override attrs. Existing on-disk records are handled by a backward-compatible serde `alias` on the variant, not a schema-version bump: old `claude-code-noninteractive` blobs still deserialize, and re-serialize under the new tag on their next write. This is an additive change (DESIGN §9.0/§9.2) — no destructive migration, no upgrade barrier. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ctzDRm2jtyFNjSE92kFYN --- .../crates/silverwood-cli/src/main.rs | 16 ++++---- .../crates/silverwood-cli/tests/cli.rs | 6 +-- .../crates/silverwood-cli/tests/e2e.rs | 10 ++--- .../crates/silverwood-core/src/doc.rs | 2 +- .../crates/silverwood-core/src/forest.rs | 8 ++-- .../crates/silverwood-core/src/lib.rs | 2 +- .../crates/silverwood-core/src/spawn.rs | 14 +++---- .../crates/silverwood-core/src/workstream.rs | 41 +++++++++++++++++-- .../silverwood-core/tests/associated_data.rs | 10 ++--- 9 files changed, 72 insertions(+), 37 deletions(-) diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 6760e0c..42b75af 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -13,7 +13,7 @@ use std::str::FromStr; use clap::{Parser, Subcommand, ValueEnum}; use silverwood_core::{ - claude_code_noninteractive_plan, claude_code_plan, disk_space_plan, plain_shell_plan, + claude_code_noninteractiveshell_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, @@ -137,7 +137,7 @@ enum SpawnCommand { }, /// A Claude Code session, run non-interactively (claude directly), with explicit /// control over loading the checkout's `.envrc`. - ClaudeCodeNoninteractive { + ClaudeCodeNoninteractiveshell { /// Whether to wrap claude in `direnv exec ` — required, `true` or `false`. #[arg(long, action = clap::ArgAction::Set, required = true)] run_direnv_exec: bool, @@ -331,7 +331,7 @@ enum SessionCreate { }, /// 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 { + ClaudeCodeNoninteractiveshell { /// The workstream id to attach the session to (from `silverwood ls`). id: String, /// The Claude Code session id to record. @@ -850,7 +850,7 @@ 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::ClaudeCodeNoninteractiveshell { id, .. }) | SessionCommand::Create(SessionCreate::DiskSpace { id, .. }) | SessionCommand::Ls { id } | SessionCommand::Rename { id, .. } @@ -878,7 +878,7 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { let name = name.unwrap_or_else(|| session_id.clone()); forest.create_session(id, &session_id, SessionKind::PlainShell {}, &name)?; } - SessionCommand::Create(SessionCreate::ClaudeCodeNoninteractive { + SessionCommand::Create(SessionCreate::ClaudeCodeNoninteractiveshell { session_id, name, run_direnv_exec, @@ -888,7 +888,7 @@ fn run_session(forest: &Forest, json: bool, cmd: SessionCommand) -> CliResult { forest.create_session( id, &session_id, - SessionKind::ClaudeCodeNoninteractive { + SessionKind::ClaudeCodeNoninteractiveshell { lock: None, run_direnv_exec, }, @@ -956,12 +956,12 @@ fn run_spawn(forest: &Forest, json: bool, what: SpawnCommand) -> CliResult { let (session_id, run, wd) = run.parts(); claude_code_plan(&wd.resolve()?, &session_id, run, &seed) } - SpawnCommand::ClaudeCodeNoninteractive { + SpawnCommand::ClaudeCodeNoninteractiveshell { run_direnv_exec, run, } => { let (session_id, run, wd) = run.parts(); - claude_code_noninteractive_plan( + claude_code_noninteractiveshell_plan( &wd.resolve()?, &session_id, run, diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index 105f6d4..90b4ba0 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -442,13 +442,13 @@ fn spawn_direct_variants_reflect_the_kind() { args[3] ); - // claude-code-noninteractive: claude directly when direnv off … + // claude-code-noninteractiveshell: claude directly when direnv off … let plan = json( &dir, &[ "--json", "spawn", - "claude-code-noninteractive", + "claude-code-noninteractiveshell", "--run-direnv-exec", "false", "resume", @@ -466,7 +466,7 @@ fn spawn_direct_variants_reflect_the_kind() { &[ "--json", "spawn", - "claude-code-noninteractive", + "claude-code-noninteractiveshell", "--run-direnv-exec", "true", "first-run", diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index 21f1393..4fcbc06 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -93,14 +93,14 @@ fn session_schema_reflects_the_session_create_subcommands() { }; // The kinds papyrus supplies id/session_id/name for; only extra options are surfaced. - // Three kinds take no options; the noninteractive kind takes a required bool. + // Three kinds take no options; the noninteractiveshell kind takes a required bool. for tag in ["claude-code", "plain-shell", "disk-space"] { assert!( kind(tag)["options"].as_array().unwrap().is_empty(), "{tag} should have no options" ); } - let opts = kind("claude-code-noninteractive")["options"] + let opts = kind("claude-code-noninteractiveshell")["options"] .as_array() .expect("options array"); assert_eq!(opts.len(), 1); @@ -351,7 +351,7 @@ fn new_direnv_unsafe_mode_is_ready() { /// 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`.) +/// is direnv-blind, and explicit `direnv exec` lives on `claude-code-noninteractiveshell`.) #[test] #[ignore = "network + jj; run via `cargo test -- --ignored`"] fn spawn_from_id_resolves_each_session_kind() { @@ -393,13 +393,13 @@ fn spawn_from_id_resolves_each_session_kind() { .contains("exec claude --session-id 'cc-1'")); assert_eq!(plan["cwd"], cwd); - // claude-code-noninteractive with direnv on: `direnv exec claude --session-id`. + // claude-code-noninteractiveshell with direnv on: `direnv exec claude --session-id`. ok( &dir, &[ "session", "create", - "claude-code-noninteractive", + "claude-code-noninteractiveshell", id, "ni-1", "--run-direnv-exec", diff --git a/experimental/silverwood/crates/silverwood-core/src/doc.rs b/experimental/silverwood/crates/silverwood-core/src/doc.rs index 9512e3e..b061e5e 100644 --- a/experimental/silverwood/crates/silverwood-core/src/doc.rs +++ b/experimental/silverwood/crates/silverwood-core/src/doc.rs @@ -301,7 +301,7 @@ pub(crate) fn set_session_lock( .map_err(|e| Error::Corrupt(format!("session {session_id}: {e}")))?; match &mut session.kind { SessionKind::ClaudeCode { lock: slot } - | SessionKind::ClaudeCodeNoninteractive { lock: slot, .. } => *slot = lock, + | SessionKind::ClaudeCodeNoninteractiveshell { 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 { diff --git a/experimental/silverwood/crates/silverwood-core/src/forest.rs b/experimental/silverwood/crates/silverwood-core/src/forest.rs index 32d8e6a..ee7b0ae 100644 --- a/experimental/silverwood/crates/silverwood-core/src/forest.rs +++ b/experimental/silverwood/crates/silverwood-core/src/forest.rs @@ -19,7 +19,7 @@ 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, + claude_code_noninteractiveshell_plan, claude_code_plan, disk_space_plan, plain_shell_plan, ClaudeRun, ShellPlan, SpawnSeed, }; use crate::workstream::{ @@ -460,7 +460,7 @@ 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 { - SessionKind::ClaudeCode { .. } | SessionKind::ClaudeCodeNoninteractive { .. } => Some( + SessionKind::ClaudeCode { .. } | SessionKind::ClaudeCodeNoninteractiveshell { .. } => Some( crate::claude::claude_conversation_exists(config_dir, session_id), ), // A shell kind has no persisted conversation to check; doctor can't @@ -513,9 +513,9 @@ impl Forest { SessionKind::ClaudeCode { .. } => { claude_code_plan(&cwd, session_id, claude_run(), seed) } - SessionKind::ClaudeCodeNoninteractive { + SessionKind::ClaudeCodeNoninteractiveshell { run_direnv_exec, .. - } => claude_code_noninteractive_plan( + } => claude_code_noninteractiveshell_plan( &cwd, session_id, claude_run(), diff --git a/experimental/silverwood/crates/silverwood-core/src/lib.rs b/experimental/silverwood/crates/silverwood-core/src/lib.rs index 6cae822..dc94852 100644 --- a/experimental/silverwood/crates/silverwood-core/src/lib.rs +++ b/experimental/silverwood/crates/silverwood-core/src/lib.rs @@ -37,7 +37,7 @@ pub use migrate::DOC_SCHEMA_VERSION; pub use provider::{CheckoutProvider, JjColocated}; pub use source::{AbsolutePath, HttpsGitUrl}; pub use spawn::{ - claude_code_noninteractive_plan, claude_code_plan, disk_space_plan, plain_shell_plan, + claude_code_noninteractiveshell_plan, claude_code_plan, disk_space_plan, plain_shell_plan, ClaudeRun, ShellPlan, SpawnSeed, }; pub use workstream::{ diff --git a/experimental/silverwood/crates/silverwood-core/src/spawn.rs b/experimental/silverwood/crates/silverwood-core/src/spawn.rs index 02aca97..ada4470 100644 --- a/experimental/silverwood/crates/silverwood-core/src/spawn.rs +++ b/experimental/silverwood/crates/silverwood-core/src/spawn.rs @@ -20,7 +20,7 @@ //! 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 +//! kind that still wraps `direnv exec` explicitly is `claude-code-noninteractiveshell`, gated on //! its own `run_direnv_exec` flag (the deterministic, rc-free counterpart). use std::collections::BTreeMap; @@ -141,12 +141,12 @@ pub fn disk_space_plan(cwd: &str, seed: &SpawnSeed) -> ShellPlan { ) } -/// The **claude-code-noninteractive** kind: `claude` run directly in the clean login env +/// The **claude-code-noninteractiveshell** 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( +pub fn claude_code_noninteractiveshell_plan( cwd: &str, session_id: &str, run: ClaudeRun, @@ -398,8 +398,8 @@ mod tests { } #[test] - fn noninteractive_runs_claude_directly_when_direnv_off() { - let plan = claude_code_noninteractive_plan( + fn noninteractiveshell_runs_claude_directly_when_direnv_off() { + let plan = claude_code_noninteractiveshell_plan( "/w/abc", "sess-1", ClaudeRun::FirstRun, @@ -411,9 +411,9 @@ mod tests { } #[test] - fn noninteractive_wraps_claude_in_direnv_exec_when_on() { + fn noninteractiveshell_wraps_claude_in_direnv_exec_when_on() { // The checkout path is a distinct argv element (no shell quoting needed). - let plan = claude_code_noninteractive_plan( + let plan = claude_code_noninteractiveshell_plan( "/w/a b/c", "sess-1", ClaudeRun::Resume, diff --git a/experimental/silverwood/crates/silverwood-core/src/workstream.rs b/experimental/silverwood/crates/silverwood-core/src/workstream.rs index 062358e..908f417 100644 --- a/experimental/silverwood/crates/silverwood-core/src/workstream.rs +++ b/experimental/silverwood/crates/silverwood-core/src/workstream.rs @@ -145,7 +145,14 @@ pub enum SessionKind { /// 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 { + /// + /// `alias` accepts the pre-rename tag `claude-code-noninteractive`: a + /// backward-compatible decode (not a schema bump), so records written before the + /// rename still read, and re-serialize under the new tag on their next write. This + /// is an *additive* change (`DESIGN.md` §9.0/§9.2) — no destructive migration, no + /// upgrade barrier. + #[serde(alias = "claude-code-noninteractive")] + ClaudeCodeNoninteractiveshell { /// The advisory resumption lock, if currently held. #[serde(default, skip_serializing_if = "Option::is_none")] lock: Option, @@ -169,7 +176,7 @@ impl SessionKind { pub fn tag(&self) -> &'static str { match self { SessionKind::ClaudeCode { .. } => "claude-code", - SessionKind::ClaudeCodeNoninteractive { .. } => "claude-code-noninteractive", + SessionKind::ClaudeCodeNoninteractiveshell { .. } => "claude-code-noninteractiveshell", SessionKind::PlainShell {} => "plain-shell", SessionKind::DiskSpace {} => "disk-space", } @@ -316,7 +323,7 @@ impl AgentSession { pub fn lock(&self) -> Option<&SessionLock> { match &self.kind { SessionKind::ClaudeCode { lock } - | SessionKind::ClaudeCodeNoninteractive { lock, .. } => lock.as_ref(), + | SessionKind::ClaudeCodeNoninteractiveshell { lock, .. } => lock.as_ref(), SessionKind::PlainShell {} | SessionKind::DiskSpace {} => None, } } @@ -676,6 +683,34 @@ mod tests { } } + /// The pre-rename tag `claude-code-noninteractive` still deserializes (via the + /// variant's serde `alias`) into the renamed variant, and re-serializes under the + /// new tag — a backward-compatible read, no schema migration. So records written + /// before the rename are read transparently and normalized on their next write. + #[test] + fn legacy_noninteractive_tag_deserializes_and_normalizes() { + let legacy = r#"{"kind":"claude-code-noninteractive","name":"ni","created_at":"2020-01-01T00:00:00Z","run_direnv_exec":true}"#; + let session: AgentSession = serde_json::from_str(legacy).unwrap(); + assert!(matches!( + session.kind, + SessionKind::ClaudeCodeNoninteractiveshell { + run_direnv_exec: true, + .. + } + )); + + // Re-serializing writes the canonical new tag (= tag()), so any rewrite of an + // old record normalizes it to the new name on disk. + let reencoded = serde_json::to_value(&session).unwrap(); + assert_eq!(reencoded["kind"], "claude-code-noninteractiveshell"); + assert_eq!(reencoded["kind"], serde_json::json!(session.kind.tag())); + + // The current tag also round-trips unchanged. + let again: AgentSession = + serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap(); + assert_eq!(again, session); + } + fn basic_body(status: Status, state: CheckoutState) -> WorkstreamBody { WorkstreamBody { name: "ws".into(), diff --git a/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs b/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs index 0a500e5..5709f4e 100644 --- a/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs +++ b/experimental/silverwood/crates/silverwood-core/tests/associated_data.rs @@ -172,12 +172,12 @@ fn session_doctor_and_lock_cover_the_new_kinds() { 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. + // claude-code-noninteractiveshell is a claude kind: doctor checks the transcript, and it locks. forest .create_session( ws.id, "ni", - SessionKind::ClaudeCodeNoninteractive { + SessionKind::ClaudeCodeNoninteractiveshell { lock: None, run_direnv_exec: true, }, @@ -185,7 +185,7 @@ fn session_doctor_and_lock_cover_the_new_kinds() { ) .unwrap(); let report = forest.doctor_session(ws.id, "ni", &claude).unwrap(); - assert_eq!(report.kind, "claude-code-noninteractive"); + assert_eq!(report.kind, "claude-code-noninteractiveshell"); assert_eq!(report.conversation_exists, Some(false)); forest.lock_session(ws.id, "ni", "A", false).unwrap(); assert_eq!( @@ -241,12 +241,12 @@ fn spawn_plan_from_session_resolves_each_kind() { 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`. + // claude-code-noninteractiveshell with direnv on: `direnv exec claude --session-id`. forest .create_session( ws.id, "ni-1", - SessionKind::ClaudeCodeNoninteractive { + SessionKind::ClaudeCodeNoninteractiveshell { lock: None, run_direnv_exec: true, }, -- 2.51.2