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
|
/// Studio - Multi-agent workspace manager for g3
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "studio")]
|
#[command(name = "studio", subcommand_required = false)]
|
||||||
#[command(about = "Manage multiple g3 agent sessions using git worktrees")]
|
#[command(about = "Manage multiple g3 agent sessions using git worktrees")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
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 a new g3 session (tails output until complete)
|
||||||
Run {
|
Run {
|
||||||
/// Agent name (e.g., carmack, torvalds). If omitted, runs g3 in one-shot mode.
|
/// Agent name (e.g., carmack, torvalds). If omitted, runs g3 in one-shot mode.
|
||||||
@@ -76,7 +87,9 @@ enum Commands {
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
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::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::Exec { agent, g3_args } => cmd_exec(agent.as_deref(), &g3_args),
|
||||||
Commands::List => cmd_list(),
|
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)
|
/// Get the path to the g3 binary (same directory as studio)
|
||||||
fn get_g3_binary_path() -> Result<PathBuf> {
|
fn get_g3_binary_path() -> Result<PathBuf> {
|
||||||
let current_exe = env::current_exe().context("Failed to get current executable path")?;
|
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 {
|
for session in sessions {
|
||||||
let status_str = match session.status {
|
let status_str = match session.status {
|
||||||
SessionStatus::Running => "🔄 running",
|
SessionStatus::Running => "🔄 running",
|
||||||
|
SessionStatus::Paused => "⏸️ paused",
|
||||||
SessionStatus::Complete => "✅ complete",
|
SessionStatus::Complete => "✅ complete",
|
||||||
SessionStatus::Failed => "❌ failed",
|
SessionStatus::Failed => "❌ failed",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Session management for studio
|
//! Session management for studio
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{Context, Result, bail};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -12,9 +12,17 @@ use uuid::Uuid;
|
|||||||
pub enum SessionStatus {
|
pub enum SessionStatus {
|
||||||
Running,
|
Running,
|
||||||
Complete,
|
Complete,
|
||||||
|
Paused,
|
||||||
Failed,
|
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
|
/// A studio session representing a g3 agent run in a worktree
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
@@ -30,6 +38,9 @@ pub struct Session {
|
|||||||
pub pid: Option<u32>,
|
pub pid: Option<u32>,
|
||||||
/// Path to the worktree
|
/// Path to the worktree
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
|
/// Type of session
|
||||||
|
#[serde(default = "default_session_type")]
|
||||||
|
pub session_type: SessionType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -46,6 +57,23 @@ impl Session {
|
|||||||
status: SessionStatus::Running,
|
status: SessionStatus::Running,
|
||||||
pid: None,
|
pid: None,
|
||||||
worktree_path: 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(())
|
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
|
/// Load a session by ID
|
||||||
pub fn load(repo_root: &Path, session_id: &str) -> Result<Session> {
|
pub fn load(repo_root: &Path, session_id: &str) -> Result<Session> {
|
||||||
let path = Self::sessions_dir(repo_root).join(format!("{}.json", session_id));
|
let path = Self::sessions_dir(repo_root).join(format!("{}.json", session_id));
|
||||||
@@ -164,3 +206,8 @@ impl Session {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default session type for backwards compatibility with existing sessions
|
||||||
|
fn default_session_type() -> SessionType {
|
||||||
|
SessionType::OneShot
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user