Add interactive mode to studio
New commands: - studio cli (alias: c) - Start a new interactive g3 session in an isolated worktree - studio resume <id> (alias: r) - Resume a paused interactive session - Bare 'studio' now defaults to 'studio cli' Session changes: - Added SessionStatus::Paused for sessions that can be resumed - Added SessionType enum (OneShot, Interactive) for future use - Interactive sessions use inherited stdio for direct TTY access - Sessions are marked as Paused when user exits g3 Workflow: 1. studio # creates worktree, runs g3 interactively 2. (work in g3, exit when done) 3. studio resume <id> # continue working 4. studio accept <id> # merge to main when finished
This commit is contained in:
@@ -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<Commands>,
|
||||
}
|
||||
|
||||
#[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<PathBuf> {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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<u32>,
|
||||
/// Path to the worktree
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// 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<Session> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user