use std::path::Path;
struct ChildGuard {
child: Option<std::process::Child>,
}
impl ChildGuard {
fn new(child: std::process::Child) -> Self {
Self { child: Some(child) }
}
fn id(&self) -> u32 {
self.child.as_ref().expect("ChildGuard consumed").id()
}
fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
let status = self.child.as_mut().expect("ChildGuard consumed").wait()?;
self.child = None; Ok(status)
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
let Some(mut child) = self.child.take() else { return };
let pid = child.id();
eprintln!("ccsm: cleaning up child process (pid {pid})");
unsafe { libc::kill(pid as i32, libc::SIGTERM); }
for _ in 0..50 {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
Err(_) => break,
}
}
eprintln!("ccsm: child pid {pid} didn't exit gracefully, sending SIGKILL");
let _ = child.kill();
let _ = child.wait();
}
}
pub fn run_resume(name: &str, workspace: &Path, home: &Path, consumer: crate::consumer::Consumer, flag_worktree: bool) -> anyhow::Result<()> {
let now = crate::registry::now_iso();
let bin = consumer.binary();
let (sid, fresh) = {
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
let (sid, is_fresh) = match reg.sessions.iter().rev().position(|e| e.name == name) {
Some(pos) => {
let i = reg.sessions.len() - 1 - pos;
let entry = &mut reg.sessions[i];
entry.status = crate::registry::SessionStatus::InProgress;
entry.started.clear();
let current = consumer.to_string();
if !entry.consumer.is_empty() && entry.consumer != current && !entry.session_id.is_empty() {
let found = consumer.find_session_file_for(home, workspace, &entry.session_id);
let location = if found.is_some() {
format!("found by {current}")
} else {
format!("stored by {} and not accessible from {}", entry.consumer, current)
};
eprintln!(
"⚠ session '{}' was created by {} but you are running as {}",
name, entry.consumer, current
);
eprintln!(" Session file is {location}.");
eprint!("Start a fresh {current} session instead? [y/N] ");
use std::io::{self, Write};
let _ = io::stdout().flush();
let mut input = String::new();
let ok = io::stdin().read_line(&mut input)
.map(|_| matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
.unwrap_or(false);
if !ok {
eprintln!();
eprintln!(" Aborted. To resume anyway without fresh:");
eprintln!(" ccsm {} (use the original agent)", entry.consumer);
eprintln!(" To start fresh:");
eprintln!(" ccsm pending {} (clears session identity)", name);
anyhow::bail!("resume aborted by user");
}
eprintln!();
entry.session_id.clear();
entry.consumer = current;
(None, false)
} else if !entry.consumer.is_empty() && entry.consumer != current {
eprintln!(
"⚠ session '{}' was created by {} but you are running as {}",
name, entry.consumer, current
);
eprintln!(" Starting a fresh session for {}.", current);
entry.consumer = current;
(None, false)
} else {
if entry.consumer.is_empty() {
entry.consumer = current;
}
if !entry.session_id.is_empty() {
let found = consumer.find_session_file_for(home, workspace, &entry.session_id);
if found.is_some() {
(Some(entry.session_id.clone()), false)
} else {
anyhow::bail!(
"session '{}' has session_id '{}' but session file not found.\n\
The session may have been deleted or cleaned.\n\
To start fresh: ccsm pending {} (clears session_id, then resume)",
name,
&entry.session_id[..entry.session_id.len().min(8)],
name,
);
}
} else {
(None, false)
}
}
}
None => {
let similar: Vec<&str> = reg
.sessions
.iter()
.map(|s| s.name.as_str())
.filter(|n| {
n.contains(name)
|| (name.len() >= 4 && crate::registry::edit_distance(n, name) <= 2)
|| crate::registry::edit_distance(n, name) <= 1
})
.take(5)
.collect();
if similar.is_empty() {
anyhow::bail!(
"no session named '{}'. Use `ccsm new {} -g \"...\"` to create one.",
name, name
);
} else {
anyhow::bail!(
"no session named '{}'. Did you mean: {}?",
name,
similar.join(", ")
);
}
}
};
reg.updated = now.clone();
reg.save()?;
(sid, is_fresh)
};
let detail_path = match crate::registry::resolve_or_create_identity() {
Ok(ctx) => crate::registry::global_detail_path(&ctx.id, name),
Err(_) => {
return Ok(());
}
};
if detail_path.exists() {
if let Ok(contents) = std::fs::read_to_string(&detail_path) {
let has_checklist = contents
.lines()
.any(|l| l.trim_start().to_lowercase().starts_with("## checklist"));
if !has_checklist {
eprintln!(
"💡 multi-step? `ccsm checklist {} --init` to add sub-task tracking",
name,
);
}
}
}
let config = crate::config::Config::load();
let should_create_worktree = match config.worktrees {
crate::config::WorktreePolicy::Required => true,
crate::config::WorktreePolicy::Optional => flag_worktree,
crate::config::WorktreePolicy::Disabled => false,
};
if should_create_worktree {
eprintln!(" preparing worktree...");
let (branch, existing_wt) = {
let reg = crate::registry::WorkspaceRegistry::load()?;
let session = reg.sessions.iter()
.find(|s| s.name == name)
.ok_or_else(|| anyhow::anyhow!("session '{}' not found", name))?;
let branch = session.branch.clone();
anyhow::ensure!(!branch.is_empty(),
"session '{}' has no target branch. Set one with `ccsm new -b <branch>`.", name,
);
let canonical = crate::commands::worktree::worktree_path_for(workspace, name);
let existing = if canonical.is_dir() { Some(canonical) } else { None };
(branch, existing)
};
let wt_path = match existing_wt {
Some(path) => {
eprintln!("📁 worktree already exists: {}", path.display());
path
}
None => crate::commands::worktree::create_worktree(workspace, name, &branch)?,
};
{
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
if let Some(entry) = reg.sessions.iter_mut().rev().find(|e| e.name == name) {
entry.use_worktree = true;
reg.updated = crate::registry::now_iso();
reg.save()?;
}
}
eprintln!("📁 worktree: {}", wt_path.display());
}
let worktree_dir: Option<std::path::PathBuf> = {
let wt = crate::commands::worktree::worktree_path_for(workspace, name);
if wt.is_dir() { Some(wt) } else { None }
};
let mut cmd = if let Some(ref wt) = worktree_dir {
eprintln!("📁 worktree: {}", wt.display());
let wt_str = wt.to_string_lossy();
let inner = match consumer {
crate::consumer::Consumer::Claude => {
let (flag, label) = if let Some(ref id) = sid {
(format!("--resume {}", id), format!("resuming {} ← {bin} --resume {}", name, &id[..id.len().min(8)]))
} else if fresh {
(String::new(), format!("starting {} ← {bin} (fresh)", name))
} else {
(String::new(), format!("starting {} ← {bin} (new session)", name))
};
println!("{}", label);
format!("cd '{}' && exec {} {} -n '{}'", wt_str, bin, flag, name)
}
crate::consumer::Consumer::Pi => {
let (flag, label) = if let Some(ref id) = sid {
(format!("--session {}", id), format!("resuming {} ← pi --session {}", name, &id[..id.len().min(8)]))
} else {
(String::new(), format!("starting {} ← pi (fresh session)", name))
};
println!("{}", label);
format!("cd '{}' && exec {} {} -n '{}'", wt_str, bin, flag, name)
}
crate::consumer::Consumer::OpenCode => {
let (flag, label) = if let Some(ref id) = sid {
(format!("-s {}", id), format!("resuming {} ← opencode -s {}", name, &id[..id.len().min(8)]))
} else {
(String::new(), format!("starting {} ← opencode (fresh)", name))
};
println!("{}", label);
format!("cd '{}' && exec opencode {}", wt_str, flag)
}
};
let mut s = std::process::Command::new("sh");
s.arg("-c").arg(&inner);
s
} else {
let mut c = std::process::Command::new(bin);
c.current_dir(workspace);
match consumer {
crate::consumer::Consumer::Claude => {
if let Some(ref id) = sid {
c.arg("--resume").arg(id);
println!("resuming {} ← {bin} --resume {}", name, &id[..id.len().min(8)]);
} else if fresh {
println!("starting {} ← {bin} (fresh)", name);
} else {
println!("starting {} ← {bin} (new session)", name);
}
c.arg("-n").arg(name);
}
crate::consumer::Consumer::Pi => {
if let Some(ref id) = sid {
c.arg("--session").arg(id);
println!("resuming {} ← pi --session {}", name, &id[..id.len().min(8)]);
} else {
println!("starting {} ← pi (fresh session)", name);
}
c.arg("-n").arg(name);
}
crate::consumer::Consumer::OpenCode => {
if let Some(ref id) = sid {
c.arg("-s").arg(id);
println!("resuming {} ← opencode -s {}", name, &id[..id.len().min(8)]);
} else {
println!("starting {} ← opencode (fresh)", name);
}
}
}
c
};
cmd.env("CCSM_SESSION", name);
if let Some(ref wt) = worktree_dir {
cmd.env("CCSM_WORKTREE", wt);
}
let mut child_guard = ChildGuard::new(cmd.spawn()?);
let child_pid = child_guard.id();
{
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
match reg.sessions.iter_mut().rev().find(|e| e.name == name) {
Some(entry) => entry.pids = vec![child_pid],
None => anyhow::bail!(
"internal error: session '{}' vanished from registry between Phase 1 and Phase 4",
name
),
}
reg.updated = crate::registry::now_iso();
reg.save()?;
}
let harvest_before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
if consumer.is_claude() {
let session_file = consumer.live_session_file(home, child_pid)
.ok_or_else(|| anyhow::anyhow!("consumer does not support PID-based session files"))?;
let mut found = false;
for _ in 0..50 {
if session_file.exists() {
found = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
if !found {
anyhow::bail!(
"{bin} did not write a session file at {} within 5s.\n\
{bin} may have failed to start. Check for errors above.",
session_file.display(),
);
}
if let Some(ref wt) = worktree_dir {
if let Ok(contents) = std::fs::read_to_string(&session_file) {
if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(obj) = json.as_object_mut() {
let wt_str = wt.to_string_lossy().to_string();
if obj.get("cwd").map_or(true, |v| v.as_str() != Some(&wt_str)) {
obj.insert("cwd".into(), serde_json::Value::String(wt_str));
if let Ok(updated) = serde_json::to_string(&json) {
let _ = std::fs::write(&session_file, &updated);
}
}
}
}
}
}
{
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
let entry = match reg.sessions.iter_mut().rev().find(|e| e.name == name) {
Some(e) => e,
None => anyhow::bail!(
"internal error: session '{}' vanished from registry between Phase 1 and Phase 6",
name
),
};
match std::fs::read_to_string(&session_file) {
Ok(contents) => match serde_json::from_str::<crate::session::Session>(&contents) {
Ok(s) => {
if entry.session_id.is_empty() {
entry.session_id = s.session_id;
}
if entry.started.is_empty() {
entry.started = crate::registry::format_ts(s.started_at);
}
reg.updated = crate::registry::now_iso();
}
Err(e) => {
eprintln!(
"warning: failed to parse session file {}: {}. \
Session tracking may be incomplete.",
session_file.display(), e
);
}
},
Err(e) => {
eprintln!(
"warning: failed to read session file {}: {}. \
Session tracking may be incomplete.",
session_file.display(), e
);
}
}
reg.save()?;
}
crate::registry::sync_status_line(name);
} else if consumer.is_opencode() && sid.is_none() {
let db_path = crate::consumer::opencode_db_path(home);
let run_dir = worktree_dir.as_ref()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_else(|| workspace.to_string_lossy().to_string());
let harvested_id = crate::consumer::opencode_harvest_session(
&db_path, &run_dir, harvest_before,
).or_else(|| {
if run_dir != workspace.to_string_lossy().as_ref() {
crate::consumer::opencode_harvest_session(
&db_path, &workspace.to_string_lossy(), harvest_before,
)
} else { None }
});
if let Some(harvested_id) = harvested_id {
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
let entry = match reg.sessions.iter_mut().rev().find(|e| e.name == name) {
Some(e) => e,
None => anyhow::bail!(
"internal error: session '{}' vanished from registry between Phase 1 and Phase 5",
name
),
};
if entry.session_id.is_empty() {
entry.session_id.clone_from(&harvested_id);
}
reg.updated = crate::registry::now_iso();
reg.save()?;
crate::registry::sync_status_line(name);
if let Err(e) = crate::consumer::opencode_update_title(&db_path, &harvested_id, name) {
eprintln!(" warning: failed to rename opencode session: {e}");
}
eprintln!(" (session tracked)");
} else {
eprintln!(" warning: could not detect new opencode session in DB within 5s");
}
} else if consumer.is_opencode() {
if let Some(ref existing_sid) = sid {
let db_path = crate::consumer::opencode_db_path(home);
if db_path.exists() {
let current_title = crate::consumer::opencode_get_title(&db_path, existing_sid);
if current_title.as_deref() != Some(name) {
if let Err(e) = crate::consumer::opencode_update_title(&db_path, existing_sid, name) {
eprintln!(" warning: failed to sync opencode title: {e}");
} else {
eprintln!(" opencode DB synced title → {name}");
}
}
}
}
eprintln!(" (session tracking active)");
} else {
eprintln!(" (session tracking active)");
}
let status = child_guard.wait()?;
{
let (mut reg, _lock) = crate::registry::WorkspaceRegistry::load_locked()?;
match reg.sessions.iter_mut().rev().find(|e| e.name == name) {
Some(entry) => {
entry.pids.clear();
reg.updated = crate::registry::now_iso();
}
None => {
eprintln!(
"warning: session '{}' not found in registry at cleanup — \
may have been removed while {bin} was running",
name,
);
}
}
reg.save()?;
}
if !status.success() {
anyhow::bail!("{bin} exited with {status}");
}
Ok(())
}