diff --git a/crates/studio/src/main.rs b/crates/studio/src/main.rs index cbc6868..24194c1 100644 --- a/crates/studio/src/main.rs +++ b/crates/studio/src/main.rs @@ -16,15 +16,26 @@ use session::{Session, SessionStatus}; /// Studio - Multi-agent workspace manager for g3 #[derive(Parser)] -#[command(name = "studio")] +#[command(name = "studio", subcommand_required = false)] #[command(about = "Manage multiple g3 agent sessions using git worktrees")] struct Cli { #[command(subcommand)] - command: Commands, + command: Option, } #[derive(Subcommand)] enum Commands { + /// Start a new interactive g3 session in an isolated worktree + #[command(alias = "c")] + Cli, + + /// Resume a paused interactive session + #[command(alias = "r")] + Resume { + /// Session ID to resume + session_id: String, + }, + /// Run a new g3 session (tails output until complete) Run { /// Agent name (e.g., carmack, torvalds). If omitted, runs g3 in one-shot mode. @@ -76,7 +87,9 @@ enum Commands { fn main() -> Result<()> { let cli = Cli::parse(); - match cli.command { + match cli.command.unwrap_or(Commands::Cli) { + Commands::Cli => cmd_cli(), + Commands::Resume { session_id } => cmd_resume(&session_id), Commands::Run { agent, accept, g3_args } => cmd_run(agent.as_deref(), accept, &g3_args), Commands::Exec { agent, g3_args } => cmd_exec(agent.as_deref(), &g3_args), Commands::List => cmd_list(), @@ -86,6 +99,98 @@ fn main() -> Result<()> { } } +/// Start a new interactive g3 session +fn cmd_cli() -> Result<()> { + let g3_binary = get_g3_binary_path()?; + let repo_root = get_repo_root()?; + let session = Session::new_interactive(); + + // Create worktree + let worktree = GitWorktree::new(&repo_root); + let worktree_path = worktree.create(&session)?; + + println!("πŸ“ Worktree: {}", worktree_path.display()); + println!("🌿 Branch: {}", session.branch_name()); + println!("πŸ†” Session: {}", session.id); + println!(); + + // Save session metadata + session.save(&repo_root, &worktree_path)?; + + // Build g3 command with inherited stdio for interactive use + let mut cmd = Command::new(&g3_binary); + cmd.arg("--workspace").arg(&worktree_path); + cmd.current_dir(&worktree_path); + cmd.stdin(Stdio::inherit()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + + // Spawn and wait + let status = cmd.status().context("Failed to run g3")?; + + // Mark session as paused (user can resume later or accept/discard) + session.mark_paused(&repo_root)?; + + println!(); + println!("Session {} paused.", session.id); + println!(); + println!("Next steps:"); + println!(" studio resume {} - Continue working", session.id); + println!(" studio accept {} - Merge changes to main", session.id); + println!(" studio discard {} - Discard changes", session.id); + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Resume a paused interactive session +fn cmd_resume(session_id: &str) -> Result<()> { + let g3_binary = get_g3_binary_path()?; + let repo_root = get_repo_root()?; + let session = Session::load(&repo_root, session_id)?; + + let worktree_path = session.worktree_path.as_ref() + .ok_or_else(|| anyhow!("Session {} has no worktree path", session_id))?; + + if !worktree_path.exists() { + bail!("Worktree for session {} no longer exists at {}", session_id, worktree_path.display()); + } + + println!("πŸ“ Resuming session {} in {}", session_id, worktree_path.display()); + println!(); + + // Build g3 command with inherited stdio + let mut cmd = Command::new(&g3_binary); + cmd.arg("--workspace").arg(worktree_path); + cmd.current_dir(worktree_path); + cmd.stdin(Stdio::inherit()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + + // Spawn and wait + let status = cmd.status().context("Failed to run g3")?; + + // Mark session as paused again + session.mark_paused(&repo_root)?; + + println!(); + println!("Session {} paused.", session_id); + println!(); + println!("Next steps:"); + println!(" studio resume {} - Continue working", session_id); + println!(" studio accept {} - Merge changes to main", session_id); + println!(" studio discard {} - Discard changes", session_id); + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} + /// Get the path to the g3 binary (same directory as studio) fn get_g3_binary_path() -> Result { let current_exe = env::current_exe().context("Failed to get current executable path")?; @@ -286,6 +391,7 @@ fn cmd_list() -> Result<()> { for session in sessions { let status_str = match session.status { SessionStatus::Running => "πŸ”„ running", + SessionStatus::Paused => "⏸️ paused", SessionStatus::Complete => "βœ… complete", SessionStatus::Failed => "❌ failed", }; diff --git a/crates/studio/src/session.rs b/crates/studio/src/session.rs index 35ddc50..6d880e6 100644 --- a/crates/studio/src/session.rs +++ b/crates/studio/src/session.rs @@ -1,6 +1,6 @@ //! Session management for studio -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; @@ -12,9 +12,17 @@ use uuid::Uuid; pub enum SessionStatus { Running, Complete, + Paused, Failed, } +/// Session type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SessionType { + OneShot, + Interactive, +} + /// A studio session representing a g3 agent run in a worktree #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { @@ -30,6 +38,9 @@ pub struct Session { pub pid: Option, /// Path to the worktree pub worktree_path: Option, + /// Type of session + #[serde(default = "default_session_type")] + pub session_type: SessionType, } impl Session { @@ -46,6 +57,23 @@ impl Session { status: SessionStatus::Running, pid: None, worktree_path: None, + session_type: SessionType::OneShot, + } + } + + /// Create a new interactive session + pub fn new_interactive() -> Self { + let full_uuid = Uuid::new_v4(); + let short_id = full_uuid.to_string()[..8].to_string(); + + Self { + id: short_id, + agent: "interactive".to_string(), + created_at: Utc::now(), + status: SessionStatus::Running, + pid: None, + worktree_path: None, + session_type: SessionType::Interactive, } } @@ -110,6 +138,20 @@ impl Session { Ok(()) } + /// Mark session as paused (for interactive sessions) + pub fn mark_paused(&self, repo_root: &Path) -> Result<()> { + let path = self.metadata_path(repo_root); + let content = fs::read_to_string(&path).context("Failed to read session metadata")?; + let mut session: Session = serde_json::from_str(&content)?; + session.status = SessionStatus::Paused; + session.pid = None; + + let json = serde_json::to_string_pretty(&session)?; + fs::write(&path, json).context("Failed to write session metadata")?; + + Ok(()) + } + /// Load a session by ID pub fn load(repo_root: &Path, session_id: &str) -> Result { let path = Self::sessions_dir(repo_root).join(format!("{}.json", session_id)); @@ -164,3 +206,8 @@ impl Session { Ok(()) } } + +/// Default session type for backwards compatibility with existing sessions +fn default_session_type() -> SessionType { + SessionType::OneShot +}