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:
Dhanji R. Prasanna
2026-01-16 06:48:24 +05:30
parent 637884f84b
commit 78f9207d27
2 changed files with 157 additions and 4 deletions

View File

@@ -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",
};

View File

@@ -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
}