use std::path::Path;
use std::sync::Arc;
use tracing::debug;
use crate::session_manager::ManagedTmuxDriver;
use super::RuntimeAdapter;
use super::RuntimeError;
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn session_id_export_prefix(session_id: &str) -> String {
format!(
"export TM_MANAGED_SESSION_ID={}; ",
shell_single_quote(session_id)
)
}
const RELAUNCH_HINT: &str = "tm: run `tm` to relaunch this session";
fn on_exit_hint_suffix() -> String {
format!("; echo {}", shell_single_quote(RELAUNCH_HINT))
}
fn env_bin_prefix(claude_bin: &str, config_dir: Option<&Path>) -> String {
match config_dir {
Some(dir) => {
let quoted = shell_single_quote(&dir.display().to_string());
format!("env -u ANTHROPIC_API_KEY CLAUDE_CONFIG_DIR={quoted} {claude_bin}")
}
None => format!("env -u ANTHROPIC_API_KEY {claude_bin}"),
}
}
fn prompt_file_flag(prompt_file: Option<&Path>) -> String {
match prompt_file {
Some(p) => format!(
" --append-system-prompt-file {}",
shell_single_quote(&p.display().to_string())
),
None => String::new(),
}
}
fn spawn_command(
claude_bin: &str,
config_dir: Option<&Path>,
session_id: &str,
prompt_file: Option<&Path>,
) -> String {
format!(
"{}{}{} {} {}{}",
session_id_export_prefix(session_id),
env_bin_prefix(claude_bin, config_dir),
prompt_file_flag(prompt_file),
crate::core::model_inject::SETTING_SOURCES_FLAG,
crate::core::model_inject::PERMISSION_MODE_FLAG,
on_exit_hint_suffix(),
)
}
fn build_prompt_file(project_dir: &Path) -> Option<std::path::PathBuf> {
let native = crate::core::output_style::claude_supports_native_output_style();
let prompt = crate::core::session_launch::build_system_prompt_for_with_style_and_native(
project_dir,
None,
native,
);
let file = crate::core::model_inject::write_prompt_file(&prompt);
if file.is_none() {
tracing::warn!(
project = %project_dir.display(),
"failed to write PM system-prompt file; spawning without --append-system-prompt-file"
);
}
file
}
fn resume_command(
claude_bin: &str,
config_dir: Option<&Path>,
claude_session_id: Option<&str>,
has_prior_conv: bool,
session_id: &str,
) -> String {
let base = format!(
"{}{} {} {}",
session_id_export_prefix(session_id),
env_bin_prefix(claude_bin, config_dir),
crate::core::model_inject::SETTING_SOURCES_FLAG,
crate::core::model_inject::PERMISSION_MODE_FLAG,
);
let cmd = match claude_session_id {
Some(id) => format!("{base} --resume {id}"),
None if has_prior_conv => format!("{base} --continue"),
None => base, };
format!("{cmd}{}", on_exit_hint_suffix())
}
fn encode_project_dir(cwd: &Path) -> String {
cwd.to_string_lossy().replace('/', "-")
}
fn has_prior_conversation(cwd: &Path) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
has_prior_conversation_in(cwd, &home.join(".claude").join("projects"))
}
fn has_prior_conversation_in(cwd: &Path, projects_dir: &Path) -> bool {
if !projects_dir.is_dir() {
return false;
}
let project_dir = projects_dir.join(encode_project_dir(cwd));
project_dir.is_dir()
&& std::fs::read_dir(&project_dir)
.map(|d| {
d.flatten()
.any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
})
.unwrap_or(false)
}
fn projects_dir_for(config_dir: Option<&Path>) -> Option<std::path::PathBuf> {
if let Some(dir) = config_dir {
return Some(dir.join("projects"));
}
dirs::home_dir().map(|h| h.join(".claude").join("projects"))
}
fn session_id_exists(cwd: &Path, config_dir: Option<&Path>, id: &str) -> bool {
match projects_dir_for(config_dir) {
Some(projects_dir) => session_id_exists_in(cwd, &projects_dir, id),
None => false,
}
}
fn session_id_exists_in(cwd: &Path, projects_dir: &Path, id: &str) -> bool {
projects_dir
.join(encode_project_dir(cwd))
.join(format!("{id}.jsonl"))
.is_file()
}
fn prepare_managed_config(tmux_name: &str, cwd: &Path) -> Option<std::path::PathBuf> {
let Some(config_dir) = crate::core::trusty_tools_config::managed_claude_config_dir() else {
if let Err(e) = crate::core::home_trust_seed::preseed_home_trust(cwd) {
tracing::warn!(
session = %tmux_name,
cwd = %cwd.display(),
"home trust pre-seed failed (non-fatal): {e}"
);
}
return None;
};
if let Err(e) = crate::core::managed_config::ensure_managed_config_dir(&config_dir) {
tracing::warn!(
session = %tmux_name,
config_dir = %config_dir.display(),
"managed config dir provisioning failed (non-fatal): {e}"
);
}
if let Err(e) = crate::core::standalone::preseed_managed_trust(&config_dir, cwd) {
tracing::warn!(
session = %tmux_name,
cwd = %cwd.display(),
"managed trust pre-seed failed (non-fatal): {e}"
);
}
Some(config_dir)
}
#[derive(Debug)]
pub struct InPlaceResumeCommand {
pub claude_bin: String,
pub args: Vec<String>,
pub config_dir: Option<std::path::PathBuf>,
}
fn compose_inplace_args(
cwd: &Path,
config_dir: Option<&Path>,
claude_session_id: Option<&str>,
) -> Vec<String> {
let effective_id = claude_session_id.filter(|id| session_id_exists(cwd, config_dir, id));
let mut args: Vec<String> = crate::core::model_inject::SETTING_SOURCES_FLAG
.split_whitespace()
.chain(crate::core::model_inject::PERMISSION_MODE_FLAG.split_whitespace())
.map(str::to_owned)
.collect();
match effective_id {
Some(id) => {
args.push("--resume".to_owned());
args.push(id.to_owned());
}
None if has_prior_conversation(cwd) => args.push("--continue".to_owned()),
None => {}
}
args
}
pub fn build_inplace_resume_command(
cwd: &Path,
claude_session_id: Option<&str>,
) -> Result<InPlaceResumeCommand, RuntimeError> {
let claude_bin = ClaudeCodeAdapter::resolve_claude().ok_or_else(|| {
RuntimeError::BinaryNotFound(
"claude binary not found on PATH or in well-known dirs \
(e.g. ~/.local/bin) — install Claude Code first"
.into(),
)
})?;
let config_dir = prepare_managed_config("in-place-relaunch", cwd);
let args = compose_inplace_args(cwd, config_dir.as_deref(), claude_session_id);
Ok(InPlaceResumeCommand {
claude_bin,
args,
config_dir,
})
}
pub struct ClaudeCodeAdapter {
tmux: Arc<dyn ManagedTmuxDriver + Send + Sync>,
}
impl ClaudeCodeAdapter {
pub fn new(tmux: Arc<dyn ManagedTmuxDriver + Send + Sync>) -> Self {
Self { tmux }
}
fn resolve_claude() -> Option<String> {
trusty_common::bin_resolve::resolve_binary("claude")
.and_then(|p| p.to_str().map(str::to_owned))
}
}
impl RuntimeAdapter for ClaudeCodeAdapter {
fn spawn(
&self,
tmux_name: &str,
cwd: &Path,
task: &str,
session_id: &str,
) -> Result<(), RuntimeError> {
let claude_bin = Self::resolve_claude().ok_or_else(|| {
RuntimeError::BinaryNotFound(
"claude binary not found on PATH or in well-known dirs \
(e.g. ~/.local/bin) — install Claude Code first"
.into(),
)
})?;
debug!(
session = %tmux_name,
cwd = %cwd.display(),
task = %task,
claude = %claude_bin,
"spawning claude-code in tmux pane"
);
let config_dir = prepare_managed_config(tmux_name, cwd);
let prompt_file = build_prompt_file(cwd);
self.tmux
.send_line(
tmux_name,
&spawn_command(
&claude_bin,
config_dir.as_deref(),
session_id,
prompt_file.as_deref(),
),
)
.map_err(|e| RuntimeError::TmuxUnavailable(e.to_string()))
}
fn spawn_resume(
&self,
tmux_name: &str,
cwd: &Path,
task: &str,
claude_session_id: Option<&str>,
session_id: &str,
) -> Result<(), RuntimeError> {
let claude_bin = Self::resolve_claude().ok_or_else(|| {
RuntimeError::BinaryNotFound(
"claude binary not found on PATH or in well-known dirs \
(e.g. ~/.local/bin) — install Claude Code first"
.into(),
)
})?;
let config_dir = prepare_managed_config(tmux_name, cwd);
let effective_id = claude_session_id.filter(|id| {
let exists = session_id_exists(cwd, config_dir.as_deref(), id);
if !exists {
tracing::warn!(
session = %tmux_name,
claude_session_id = %id,
"stored claude_session_id no longer resolves to a session on \
disk; falling back instead of passing --resume (#2013)"
);
}
exists
});
if let Some(id) = effective_id {
tracing::warn!(
session = %tmux_name,
claude_session_id = %id,
"resuming with --resume <id>; if conversation no longer exists on \
disk, Claude Code will error — the reap loop will detect and mark \
Stopped within ~60 s (#1744)"
);
}
let file_history = effective_id.is_none() && has_prior_conversation(cwd);
let prior = effective_id.is_some() || file_history;
debug!(
session = %tmux_name,
cwd = %cwd.display(),
task = %task,
claude = %claude_bin,
resume = effective_id.is_some(),
has_prior_conv = file_history, "resuming claude-code in tmux pane"
);
self.tmux
.send_line(
tmux_name,
&resume_command(
&claude_bin,
config_dir.as_deref(),
effective_id,
prior,
session_id,
),
)
.map_err(|e| RuntimeError::TmuxUnavailable(e.to_string()))
}
fn identify(&self) -> &str {
"claude-code"
}
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::FakeTmux;
use super::*;
const TEST_SESSION_ID: &str = "11111111-2222-3333-4444-555555555555";
struct HomeGuard {
prev: Option<String>,
_tmp: tempfile::TempDir,
}
impl HomeGuard {
fn set() -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
Self { prev, _tmp: tmp }
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.prev {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[test]
fn claude_code_adapter_identifies() {
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake);
assert_eq!(adapter.identify(), "claude-code");
}
#[test]
fn spawn_command_contains_env_scrub() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
assert!(
cmd.contains("env -u ANTHROPIC_API_KEY"),
"spawn command must contain env scrub: {cmd}"
);
assert!(
cmd.contains(" claude "),
"spawn command must invoke claude: {cmd}"
);
}
#[test]
fn spawn_command_exports_managed_session_id() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
let expected_prefix = format!("export TM_MANAGED_SESSION_ID='{TEST_SESSION_ID}'; ");
assert!(
cmd.starts_with(&expected_prefix),
"spawn command must start with the session-id export: {cmd}"
);
let export_pos = cmd
.find("export TM_MANAGED_SESSION_ID")
.expect("export present");
let claude_pos = cmd.find(" claude ").expect("claude invocation present");
assert!(
export_pos < claude_pos,
"export must precede the claude invocation: {cmd}"
);
}
#[test]
fn spawn_command_sets_claude_config_dir() {
let dir = Path::new("/home/bob/.trusty-tools/trusty-mpm/claude-config");
let cmd = spawn_command("claude", Some(dir), TEST_SESSION_ID, None);
assert!(
cmd.contains(
"env -u ANTHROPIC_API_KEY \
CLAUDE_CONFIG_DIR='/home/bob/.trusty-tools/trusty-mpm/claude-config' claude"
),
"spawn command must scrub the key (via -u, BEFORE the NAME=VALUE assignment per \
POSIX env grammar) then set (single-quoted) CLAUDE_CONFIG_DIR: {cmd}"
);
assert!(
cmd.contains("--setting-sources project,local"),
"isolation flag must still be present with a config dir: {cmd}"
);
}
#[test]
fn env_bin_prefix_orders_unset_flag_before_config_dir_assignment() {
let dir = Path::new("/home/bob/.trusty-tools/trusty-mpm/claude-config");
let cmd = spawn_command("claude", Some(dir), TEST_SESSION_ID, None);
let u_pos = cmd
.find("-u ANTHROPIC_API_KEY")
.expect("cmd must contain -u ANTHROPIC_API_KEY");
let config_pos = cmd
.find("CLAUDE_CONFIG_DIR=")
.expect("cmd must contain CLAUDE_CONFIG_DIR=");
assert!(
u_pos < config_pos,
"-u ANTHROPIC_API_KEY must appear BEFORE CLAUDE_CONFIG_DIR= per POSIX env \
option-then-assignment grammar: {cmd}"
);
}
#[test]
fn spawn_command_without_config_dir_omits_it() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
assert!(
!cmd.contains("CLAUDE_CONFIG_DIR"),
"no config dir → must not reference CLAUDE_CONFIG_DIR: {cmd}"
);
}
#[test]
fn env_bin_prefix_quotes_config_dir_with_space() {
let dir = Path::new("/Users/John Doe/.trusty-tools/trusty-mpm/claude-config");
let cmd = spawn_command("claude", Some(dir), TEST_SESSION_ID, None);
assert!(
cmd.contains(
"env -u ANTHROPIC_API_KEY \
CLAUDE_CONFIG_DIR='/Users/John Doe/.trusty-tools/trusty-mpm/claude-config' claude"
),
"config dir with a space must be single-quoted and intact: {cmd}"
);
assert!(
!cmd.contains("CLAUDE_CONFIG_DIR=/Users/John Doe"),
"config dir must never be interpolated unquoted: {cmd}"
);
let resume = resume_command("claude", Some(dir), None, false, TEST_SESSION_ID);
assert!(
resume.contains(
"CLAUDE_CONFIG_DIR='/Users/John Doe/.trusty-tools/trusty-mpm/claude-config'"
),
"resume command must also single-quote a spaced config dir: {resume}"
);
}
#[test]
fn shell_single_quote_escapes_embedded_quote() {
assert_eq!(shell_single_quote("/a/b"), "'/a/b'");
assert_eq!(
shell_single_quote("/Users/o'brien/cfg"),
r"'/Users/o'\''brien/cfg'"
);
}
#[test]
fn spawn_command_contains_isolation_flags() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
assert!(
cmd.contains("--setting-sources project,local"),
"spawn command must isolate settings: {cmd}"
);
assert!(
cmd.contains("--dangerously-skip-permissions"),
"spawn command must bypass permissions for unattended orchestration: {cmd}"
);
assert!(
!cmd.contains("acceptEdits"),
"old acceptEdits flag must not be present: {cmd}"
);
}
#[test]
fn spawn_command_uses_resolved_binary() {
let cmd = spawn_command("/Users/me/.local/bin/claude", None, TEST_SESSION_ID, None);
assert!(
cmd.contains("env -u ANTHROPIC_API_KEY /Users/me/.local/bin/claude "),
"spawn command must invoke the resolved absolute claude path: {cmd}"
);
}
#[test]
fn claude_code_adapter_binary_check_returns_option() {
if let Some(p) = ClaudeCodeAdapter::resolve_claude() {
assert!(!p.is_empty(), "resolved claude path must be non-empty");
}
}
#[serial_test::serial]
#[test]
fn spawn_sends_env_scrub_when_binary_available() {
let _home = HomeGuard::set();
let Some(_claude_bin) = ClaudeCodeAdapter::resolve_claude() else {
return;
};
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone());
adapter
.spawn("tmpm-test", Path::new("/tmp"), "some task", TEST_SESSION_ID)
.expect("spawn");
let sends = fake.sends.lock().unwrap();
assert_eq!(sends.len(), 1);
assert_eq!(sends[0].0, "tmpm-test");
let cmd = &sends[0].1;
assert!(cmd.contains("-u ANTHROPIC_API_KEY"));
assert!(
cmd.contains("--append-system-prompt-file"),
"spawn command must inject the PM system prompt file: {cmd}"
);
}
#[test]
fn spawn_command_with_prompt_file_contains_flag() {
let path = Path::new("/tmp/trusty-mpm-system-prompt-test.txt");
let cmd = spawn_command("claude", None, TEST_SESSION_ID, Some(path));
assert!(
cmd.contains("--append-system-prompt-file '/tmp/trusty-mpm-system-prompt-test.txt'"),
"spawn command must pass the prompt file via --append-system-prompt-file: {cmd}"
);
assert!(
cmd.contains("--setting-sources project,local"),
"isolation flags must still be present alongside the prompt file: {cmd}"
);
}
#[test]
fn spawn_command_without_prompt_file_omits_flag() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
assert!(
!cmd.contains("--append-system-prompt-file"),
"no prompt file → flag must be absent: {cmd}"
);
}
#[test]
fn build_prompt_file_writes_resolved_prompt_for_project() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = build_prompt_file(tmp.path()).expect("prompt file written");
let content = std::fs::read_to_string(&path).expect("prompt file readable");
assert!(
content.contains("# PM Agent -- Trusty MPM"),
"prompt file must contain the resolved PM system prompt: {content}"
);
std::fs::remove_file(&path).ok();
}
#[test]
fn resume_command_with_id_uses_resume_flag() {
let cmd = resume_command("claude", None, Some("abc-123"), false, TEST_SESSION_ID);
assert!(
cmd.contains("--resume abc-123"),
"resume command must include --resume <id>: {cmd}"
);
assert!(
cmd.contains("env -u ANTHROPIC_API_KEY"),
"resume command must still scrub API key: {cmd}"
);
assert!(
!cmd.contains("--continue"),
"resume command must NOT contain --continue when id is set: {cmd}"
);
}
#[test]
fn resume_command_exports_managed_session_id() {
let cmd = resume_command("claude", None, Some("abc-123"), false, TEST_SESSION_ID);
let expected_prefix = format!("export TM_MANAGED_SESSION_ID='{TEST_SESSION_ID}'; ");
assert!(
cmd.starts_with(&expected_prefix),
"resume command must start with the session-id export: {cmd}"
);
let export_pos = cmd
.find("export TM_MANAGED_SESSION_ID")
.expect("export present");
let resume_pos = cmd.find("--resume").expect("--resume flag present");
assert!(
export_pos < resume_pos,
"export must precede the --resume flag: {cmd}"
);
}
#[test]
fn resume_command_sets_claude_config_dir() {
let dir = Path::new("/home/bob/.trusty-tools/trusty-mpm/claude-config");
let cmd = resume_command("claude", Some(dir), Some("abc-123"), false, TEST_SESSION_ID);
assert!(
cmd.contains(
"env -u ANTHROPIC_API_KEY \
CLAUDE_CONFIG_DIR='/home/bob/.trusty-tools/trusty-mpm/claude-config' claude"
),
"resume command must export (single-quoted) CLAUDE_CONFIG_DIR after the -u option: {cmd}"
);
assert!(
cmd.contains("--resume abc-123"),
"resume command must still include --resume <id>: {cmd}"
);
}
#[test]
fn resume_command_without_id_with_prior_conv_uses_continue() {
let cmd = resume_command("claude", None, None, true, TEST_SESSION_ID);
assert!(
cmd.contains("--continue"),
"resume command without id + prior conv must use --continue: {cmd}"
);
assert!(
!cmd.contains("--resume"),
"resume command without id must NOT contain --resume: {cmd}"
);
}
#[test]
fn resume_command_without_id_no_prior_conv_uses_plain_spawn() {
let cmd = resume_command("claude", None, None, false, TEST_SESSION_ID);
assert!(
!cmd.contains("--continue"),
"resume command without id + no prior conv must NOT use --continue: {cmd}"
);
assert!(
!cmd.contains("--resume"),
"resume command without id must NOT use --resume: {cmd}"
);
assert!(
cmd.contains("env -u ANTHROPIC_API_KEY"),
"plain spawn command must still scrub API key: {cmd}"
);
}
#[test]
fn has_prior_conversation_returns_false_for_fresh_workspace() {
let tmp = tempfile::tempdir().expect("tempdir");
let projects_dir = tmp.path().join("projects");
assert!(
!has_prior_conversation_in(tmp.path(), &projects_dir),
"no projects dir → false"
);
std::fs::create_dir_all(&projects_dir).unwrap();
assert!(
!has_prior_conversation_in(tmp.path(), &projects_dir),
"projects dir exists but no entry for this workspace → false"
);
}
#[test]
fn has_prior_conversation_returns_true_when_jsonl_exists() {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().join("my-workspace");
std::fs::create_dir_all(&cwd).unwrap();
let projects_dir = tmp.path().join("projects");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = projects_dir.join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("session.jsonl"), "{}").unwrap();
assert!(
has_prior_conversation_in(&cwd, &projects_dir),
"workspace with .jsonl must return true"
);
}
#[serial_test::serial]
#[test]
fn spawn_resume_with_id_uses_resume_flag() {
let _home = HomeGuard::set();
if ClaudeCodeAdapter::resolve_claude().is_none() {
return;
};
let cwd = Path::new("/tmp");
let config_dir = crate::core::trusty_tools_config::managed_claude_config_dir()
.expect("config dir resolves under redirected HOME");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = config_dir.join("projects").join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("my-session-id.jsonl"), "{}").unwrap();
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone());
adapter
.spawn_resume(
"tmpm-test",
cwd,
"task",
Some("my-session-id"),
TEST_SESSION_ID,
)
.expect("spawn_resume");
let sends = fake.sends.lock().unwrap();
assert_eq!(sends.len(), 1);
assert!(
sends[0].1.contains("--resume my-session-id"),
"spawn_resume with an existing id must use --resume: {}",
sends[0].1
);
}
#[serial_test::serial]
#[test]
fn spawn_resume_with_missing_id_falls_back_gracefully() {
let _home = HomeGuard::set();
if ClaudeCodeAdapter::resolve_claude().is_none() {
return;
};
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone());
adapter
.spawn_resume(
"tmpm-test",
Path::new("/tmp/does-not-exist-2013"),
"task",
Some("stale-session-id"),
TEST_SESSION_ID,
)
.expect("spawn_resume must not hard-fail on a missing id");
let sends = fake.sends.lock().unwrap();
assert_eq!(sends.len(), 1);
assert!(
!sends[0].1.contains("--resume"),
"a missing id must NOT be passed to --resume: {}",
sends[0].1
);
assert!(
!sends[0].1.contains("--continue"),
"a missing id with no prior conversation history must fall back to a \
plain spawn, not --continue: {}",
sends[0].1
);
assert!(
sends[0].1.contains("env -u ANTHROPIC_API_KEY"),
"fallback command must still scrub the API key: {}",
sends[0].1
);
}
#[test]
fn session_id_exists_true_for_real_jsonl_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().join("my-workspace");
std::fs::create_dir_all(&cwd).unwrap();
let projects_dir = tmp.path().join("projects");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = projects_dir.join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("abc-123.jsonl"), "{}").unwrap();
assert!(
session_id_exists_in(&cwd, &projects_dir, "abc-123"),
"existing session file must resolve as present"
);
}
#[test]
fn session_id_exists_false_for_missing_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().join("my-workspace");
std::fs::create_dir_all(&cwd).unwrap();
let projects_dir = tmp.path().join("projects");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = projects_dir.join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("other-session.jsonl"), "{}").unwrap();
assert!(
!session_id_exists_in(&cwd, &projects_dir, "stale-id-not-present"),
"id with no matching file must resolve as absent"
);
}
#[test]
fn session_id_exists_false_when_projects_dir_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing_projects_dir = tmp.path().join("does-not-exist");
assert!(
!session_id_exists_in(tmp.path(), &missing_projects_dir, "any-id"),
"missing projects dir must resolve as absent, not panic"
);
assert!(
!session_id_exists(tmp.path(), None, "any-id"),
"session_id_exists with no config dir falls back to home resolution \
and must never panic even if HOME is unusual in the test env"
);
}
#[test]
fn encode_project_dir_replaces_slashes() {
assert_eq!(
encode_project_dir(Path::new("/private/tmp/foo")),
"-private-tmp-foo"
);
}
#[test]
fn session_id_exists_finds_hardcoded_dir_name_for_known_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
let projects_dir = tmp.path().join("projects");
let project_dir = projects_dir.join("-tmp-my-workspace");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("known-id.jsonl"), "{}").unwrap();
assert!(
session_id_exists_in(Path::new("/tmp/my-workspace"), &projects_dir, "known-id"),
"session_id_exists_in must find the seeded session under the \
hand-typed expected dir name '-tmp-my-workspace'"
);
}
#[test]
fn projects_dir_for_prefers_config_dir_when_present() {
let config_dir = Path::new("/tmp/some-managed-config-dir");
assert_eq!(
projects_dir_for(Some(config_dir)),
Some(config_dir.join("projects")),
"projects_dir_for must nest under the given config dir"
);
}
#[serial_test::serial]
#[test]
fn spawn_resume_without_id_no_prior_conv_sends_plain_spawn() {
let cmd = resume_command("__fake_claude__", None, None, false, TEST_SESSION_ID);
assert!(
!cmd.contains("--continue"),
"plain-spawn path must NOT use --continue: {cmd}"
);
assert!(
!cmd.contains("--resume"),
"plain-spawn path must NOT use --resume: {cmd}"
);
assert!(
cmd.contains("env -u ANTHROPIC_API_KEY"),
"plain-spawn path must still scrub API key: {cmd}"
);
let _home = HomeGuard::set();
let Some(_claude_bin) = ClaudeCodeAdapter::resolve_claude() else {
return; };
let tmp = tempfile::tempdir().expect("tempdir");
let fake = FakeTmux::new();
let adapter = ClaudeCodeAdapter::new(fake.clone());
adapter
.spawn_resume(
"test-tmux-session",
tmp.path(),
"task",
None,
TEST_SESSION_ID,
)
.expect("spawn_resume without id");
let sends = fake.sends.lock().unwrap();
assert_eq!(sends.len(), 1);
assert!(
!sends[0].1.contains("--continue"),
"adapter plain-spawn must NOT use --continue: {}",
sends[0].1
);
assert!(
!sends[0].1.contains("--resume"),
"adapter plain-spawn must NOT use --resume: {}",
sends[0].1
);
}
#[test]
fn spawn_command_prints_relaunch_hint_after_claude_exits() {
let cmd = spawn_command("claude", None, TEST_SESSION_ID, None);
assert!(
cmd.contains("; echo 'tm: run `tm` to relaunch this session'"),
"spawn command must print the relaunch hint after claude exits: {cmd}"
);
let claude_pos = cmd.find(" claude ").expect("claude invocation present");
let hint_pos = cmd.find("; echo").expect("relaunch hint present");
assert!(
claude_pos < hint_pos,
"relaunch hint must come AFTER the claude invocation: {cmd}"
);
}
#[test]
fn resume_command_prints_relaunch_hint_after_claude_exits() {
let cmd = resume_command("claude", None, Some("abc-123"), false, TEST_SESSION_ID);
assert!(
cmd.contains("; echo 'tm: run `tm` to relaunch this session'"),
"resume command must print the relaunch hint after claude exits: {cmd}"
);
let resume_pos = cmd.find("--resume").expect("--resume flag present");
let hint_pos = cmd.find("; echo").expect("relaunch hint present");
assert!(
resume_pos < hint_pos,
"relaunch hint must come AFTER --resume: {cmd}"
);
}
#[test]
fn compose_inplace_args_uses_resume_for_existing_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().join("workspace");
std::fs::create_dir_all(&cwd).unwrap();
let config_dir = tmp.path().join("config");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = config_dir.join("projects").join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("existing-id.jsonl"), "{}").unwrap();
let args = compose_inplace_args(&cwd, Some(&config_dir), Some("existing-id"));
assert!(
args.windows(2).any(|w| w == ["--resume", "existing-id"]),
"must select --resume <id> for an id that exists on disk: {args:?}"
);
assert!(
!args.contains(&"--continue".to_owned()),
"must not ALSO pass --continue when --resume is used: {args:?}"
);
}
#[test]
fn compose_inplace_args_falls_back_for_missing_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().join("workspace-missing");
std::fs::create_dir_all(&cwd).unwrap();
let config_dir = tmp.path().join("config");
let args = compose_inplace_args(&cwd, Some(&config_dir), Some("stale-id"));
assert!(
!args.contains(&"--resume".to_owned()),
"a stale id must not be passed to --resume: {args:?}"
);
assert!(
!args.contains(&"--continue".to_owned()),
"no prior conversation history: must not fall back to --continue: {args:?}"
);
assert!(
args.contains(&"--setting-sources".to_owned()),
"isolation flags must still be present: {args:?}"
);
}
#[serial_test::serial]
#[test]
fn compose_inplace_args_uses_continue_when_no_id_but_prior_conv() {
let _home = HomeGuard::set();
let home = dirs::home_dir().expect("home resolves under redirected HOME");
let cwd = std::path::PathBuf::from("/tmp/inplace-continue-test");
let encoded = cwd.to_string_lossy().replace('/', "-");
let project_dir = home.join(".claude").join("projects").join(&encoded);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("some-other-session.jsonl"), "{}").unwrap();
let args = compose_inplace_args(&cwd, None, Some("stale-id"));
assert!(
!args.contains(&"--resume".to_owned()),
"a stale id must not be passed to --resume: {args:?}"
);
assert!(
args.contains(&"--continue".to_owned()),
"prior conversation history exists: must fall back to --continue: {args:?}"
);
}
#[serial_test::serial]
#[test]
fn build_inplace_resume_command_resolves_claude_binary() {
let _home = HomeGuard::set();
let Some(claude_bin) = ClaudeCodeAdapter::resolve_claude() else {
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let result =
build_inplace_resume_command(tmp.path(), Some("some-id")).expect("build succeeds");
assert_eq!(result.claude_bin, claude_bin);
assert!(
result.args.contains(&"--setting-sources".to_owned()),
"isolation flags must be present: {:?}",
result.args
);
}
}