Add studio tool for multi-agent workspace management

Studio enables running multiple g3 agents concurrently without conflicts
by using git worktrees for isolation.

Features:
- studio run --agent <name> [args...]: Create worktree, spawn g3, tail output
- studio list: Show all active sessions
- studio status <id>: Show session details and summary
- studio accept <id>: Merge session branch to main and cleanup
- studio discard <id>: Delete session without merging

Each session gets:
- Isolated worktree at .worktrees/sessions/<agent>/<session-id>
- Dedicated branch: sessions/<agent>/<session-id>
- Short UUID (8 chars) for easy reference
- Automatic --workspace and --agent flags passed to g3
This commit is contained in:
Dhanji R. Prasanna
2026-01-12 07:26:17 +05:30
parent 02799a8e69
commit 6c17f269d7
7 changed files with 792 additions and 1 deletions

3
.gitignore vendored
View File

@@ -31,3 +31,6 @@ logs/
requirements.md requirements.md
todo.g3.md todo.g3.md
tmp/ tmp/
# Studio worktrees
.worktrees/

13
Cargo.lock generated
View File

@@ -3437,6 +3437,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "studio"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"serde",
"serde_json",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.108" version = "2.0.108"

View File

@@ -7,7 +7,8 @@ members = [
"crates/g3-config", "crates/g3-config",
"crates/g3-execution", "crates/g3-execution",
"crates/g3-computer-control", "crates/g3-computer-control",
"crates/g3-ensembles" "crates/g3-ensembles",
"crates/studio"
] ]
resolver = "2" resolver = "2"

18
crates/studio/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "studio"
version = "0.1.0"
edition = "2021"
description = "Multi-agent workspace manager for G3"
[[bin]]
name = "studio"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }

180
crates/studio/src/git.rs Normal file
View File

@@ -0,0 +1,180 @@
//! Git worktree management for studio sessions
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::session::Session;
/// Manages git worktrees for studio sessions
pub struct GitWorktree {
repo_root: PathBuf,
}
impl GitWorktree {
pub fn new(repo_root: &Path) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
}
}
/// Get the base directory for all worktrees
fn worktrees_base(&self) -> PathBuf {
self.repo_root.join(".worktrees").join("sessions")
}
/// Get the worktree path for a session
pub fn worktree_path(&self, session: &Session) -> PathBuf {
self.worktrees_base()
.join(&session.agent)
.join(&session.id)
}
/// Create a new worktree for a session
pub fn create(&self, session: &Session) -> Result<PathBuf> {
let worktree_path = self.worktree_path(session);
let branch_name = session.branch_name();
// Ensure parent directory exists
if let Some(parent) = worktree_path.parent() {
std::fs::create_dir_all(parent)
.context("Failed to create worktree parent directory")?;
}
// Create the worktree with a new branch
// git worktree add -b <branch> <path>
let output = Command::new("git")
.current_dir(&self.repo_root)
.args([
"worktree",
"add",
"-b",
&branch_name,
worktree_path.to_str().unwrap(),
])
.output()
.context("Failed to run git worktree add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create worktree: {}", stderr);
}
Ok(worktree_path)
}
/// Remove a worktree and its branch
pub fn remove(&self, session: &Session) -> Result<()> {
let worktree_path = self.worktree_path(session);
let branch_name = session.branch_name();
// Remove the worktree (force to handle uncommitted changes)
if worktree_path.exists() {
let output = Command::new("git")
.current_dir(&self.repo_root)
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.output()
.context("Failed to run git worktree remove")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Don't fail if worktree is already gone
if !stderr.contains("is not a working tree") {
bail!("Failed to remove worktree: {}", stderr);
}
}
}
// Prune worktrees to clean up any stale entries
let _ = Command::new("git")
.current_dir(&self.repo_root)
.args(["worktree", "prune"])
.output();
// Delete the branch
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["branch", "-D", &branch_name])
.output()
.context("Failed to run git branch -D")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Don't fail if branch doesn't exist
if !stderr.contains("not found") {
bail!("Failed to delete branch: {}", stderr);
}
}
// Clean up empty directories
let agent_dir = self.worktrees_base().join(&session.agent);
if agent_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&agent_dir) {
if entries.count() == 0 {
let _ = std::fs::remove_dir(&agent_dir);
}
}
}
Ok(())
}
/// Merge a branch to main
pub fn merge_to_main(&self, branch_name: &str) -> Result<()> {
// First, checkout main
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["checkout", "main"])
.output()
.context("Failed to checkout main")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to checkout main: {}", stderr);
}
// Merge the branch (allow merge commits)
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["merge", branch_name, "-m", &format!("Merge {}", branch_name)])
.output()
.context("Failed to merge branch")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to merge branch: {}", stderr);
}
Ok(())
}
/// List all worktrees
pub fn list(&self) -> Result<Vec<String>> {
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["worktree", "list", "--porcelain"])
.output()
.context("Failed to list worktrees")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut worktrees = Vec::new();
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
worktrees.push(path.to_string());
}
}
Ok(worktrees)
}
}

410
crates/studio/src/main.rs Normal file
View File

@@ -0,0 +1,410 @@
use anyhow::{anyhow, bail, Context, Result};
use clap::{Parser, Subcommand};
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
mod git;
mod session;
use git::GitWorktree;
use session::{Session, SessionStatus};
/// Studio - Multi-agent workspace manager for G3
#[derive(Parser)]
#[command(name = "studio")]
#[command(about = "Manage multiple G3 agent sessions using git worktrees")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Run a new G3 agent session (tails output until complete)
Run {
/// Agent name (e.g., carmack, torvalds)
#[arg(long)]
agent: String,
/// Additional arguments to pass to g3
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
g3_args: Vec<String>,
},
/// Execute a G3 agent session in detached mode (for future use)
Exec {
/// Agent name (e.g., carmack, torvalds)
#[arg(long)]
agent: String,
/// Additional arguments to pass to g3
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
g3_args: Vec<String>,
},
/// List all active sessions
List,
/// Show status of a session
Status {
/// Session ID
session_id: String,
},
/// Accept a session: merge to main and cleanup
Accept {
/// Session ID
session_id: String,
},
/// Discard a session: delete without merging
Discard {
/// Session ID
session_id: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Run { agent, g3_args } => cmd_run(&agent, &g3_args),
Commands::Exec { agent, g3_args } => cmd_exec(&agent, &g3_args),
Commands::List => cmd_list(),
Commands::Status { session_id } => cmd_status(&session_id),
Commands::Accept { session_id } => cmd_accept(&session_id),
Commands::Discard { session_id } => cmd_discard(&session_id),
}
}
/// 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")?;
let exe_dir = current_exe
.parent()
.ok_or_else(|| anyhow!("Failed to get executable directory"))?;
let g3_path = exe_dir.join("g3");
if !g3_path.exists() {
bail!(
"g3 binary not found at {:?}. Ensure g3 is built and in the same directory as studio.",
g3_path
);
}
Ok(g3_path)
}
/// Get the repository root (where .git is)
fn get_repo_root() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
bail!("Not in a git repository");
}
let path = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
Ok(PathBuf::from(path))
}
/// Run a new G3 session (foreground, tails output)
fn cmd_run(agent: &str, g3_args: &[String]) -> Result<()> {
let g3_binary = get_g3_binary_path()?;
let repo_root = get_repo_root()?;
let session = Session::new(agent);
// Create worktree
let worktree = GitWorktree::new(&repo_root);
let worktree_path = worktree.create(&session)?;
println!("📁 Created worktree: {}", worktree_path.display());
println!("🌿 Branch: {}", session.branch_name());
println!("🆔 Session: {}", session.id);
println!();
// Build g3 command with --workspace prepended
let mut cmd = Command::new(&g3_binary);
cmd.arg("--workspace").arg(&worktree_path);
cmd.arg("--agent").arg(agent);
cmd.args(g3_args);
cmd.current_dir(&worktree_path);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Save session metadata
session.save(&repo_root, &worktree_path)?;
println!("🚀 Starting g3 agent '{}'...", agent);
println!("{}", "".repeat(60));
// Spawn and tail output
let mut child = cmd.spawn().context("Failed to spawn g3 process")?;
// Update session with PID
session.update_pid(&repo_root, child.id())?;
// Tail stdout in a separate thread
let stdout = child.stdout.take().expect("Failed to capture stdout");
let stderr = child.stderr.take().expect("Failed to capture stderr");
let stdout_handle = std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
if let Ok(line) = line {
println!("{}", line);
}
}
});
let stderr_handle = std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines() {
if let Ok(line) = line {
eprintln!("{}", line);
}
}
});
// Wait for process to complete
let status = child.wait().context("Failed to wait for g3 process")?;
stdout_handle.join().ok();
stderr_handle.join().ok();
println!("{}", "".repeat(60));
// Update session status
session.mark_complete(&repo_root, status.success())?;
if status.success() {
println!("✅ Session {} completed successfully", session.id);
println!();
println!("Next steps:");
println!(" studio accept {} - Merge changes to main", session.id);
println!(" studio discard {} - Discard changes", session.id);
} else {
println!("❌ Session {} failed (exit code: {:?})", session.id, status.code());
}
Ok(())
}
/// Execute a G3 session in detached mode (placeholder for future)
fn cmd_exec(agent: &str, g3_args: &[String]) -> Result<()> {
// For now, just print what would happen
println!("exec command not yet implemented");
println!("Would run agent '{}' with args: {:?}", agent, g3_args);
Ok(())
}
/// List all sessions
fn cmd_list() -> Result<()> {
let repo_root = get_repo_root()?;
let sessions = Session::list_all(&repo_root)?;
if sessions.is_empty() {
println!("No active sessions.");
return Ok(());
}
println!("{:<12} {:<12} {:<10} {:<20}", "SESSION", "AGENT", "STATUS", "CREATED");
println!("{}", "".repeat(60));
for session in sessions {
let status_str = match session.status {
SessionStatus::Running => "🔄 running",
SessionStatus::Complete => "✅ complete",
SessionStatus::Failed => "❌ failed",
};
println!(
"{:<12} {:<12} {:<10} {:<20}",
session.id,
session.agent,
status_str,
session.created_at.format("%Y-%m-%d %H:%M")
);
}
Ok(())
}
/// Show status of a specific session
fn cmd_status(session_id: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
println!("Session: {}", session.id);
println!("Agent: {}", session.agent);
println!("Branch: {}", session.branch_name());
println!("Created: {}", session.created_at.format("%Y-%m-%d %H:%M:%S"));
println!("Status: {:?}", session.status);
if let Some(path) = &session.worktree_path {
println!("Worktree: {}", path.display());
}
// Check if process is still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
let is_running = is_process_running(pid);
if is_running {
println!("Process: Running (PID {})", pid);
} else {
println!("Process: Not running (stale session)");
}
}
}
// Try to extract summary from session logs if complete
if session.status != SessionStatus::Running {
if let Some(summary) = extract_session_summary(&session) {
println!();
println!("Summary:");
println!("{}", summary);
}
}
Ok(())
}
/// Accept a session: merge to main and cleanup
fn cmd_accept(session_id: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
// Check session is not still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
if is_process_running(pid) {
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
}
}
}
let worktree = GitWorktree::new(&repo_root);
let branch_name = session.branch_name();
println!("🔀 Merging {} to main...", branch_name);
// Merge the branch to main
worktree.merge_to_main(&branch_name)?;
println!("🧹 Cleaning up worktree and branch...");
// Remove worktree and branch
worktree.remove(&session)?;
// Remove session metadata
session.delete(&repo_root)?;
println!("✅ Session {} accepted and merged to main", session_id);
Ok(())
}
/// Discard a session: delete without merging
fn cmd_discard(session_id: &str) -> Result<()> {
let repo_root = get_repo_root()?;
let session = Session::load(&repo_root, session_id)?;
// Check session is not still running
if session.status == SessionStatus::Running {
if let Some(pid) = session.pid {
if is_process_running(pid) {
bail!("Session {} is still running (PID {}). Wait for it to complete or kill it first.", session_id, pid);
}
}
}
let worktree = GitWorktree::new(&repo_root);
println!("🗑️ Discarding session {}...", session_id);
// Remove worktree and branch
worktree.remove(&session)?;
// Remove session metadata
session.delete(&repo_root)?;
println!("✅ Session {} discarded", session_id);
Ok(())
}
/// Check if a process is running by PID
fn is_process_running(pid: u32) -> bool {
// Use kill -0 to check if process exists
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Extract summary from session logs
fn extract_session_summary(session: &Session) -> Option<String> {
// Look for session logs in the worktree's .g3 directory
let worktree_path = session.worktree_path.as_ref()?;
let session_dir = worktree_path.join(".g3").join("sessions");
if !session_dir.exists() {
return None;
}
// Find the most recent session log
let mut latest_log: Option<(PathBuf, std::time::SystemTime)> = None;
if let Ok(entries) = fs::read_dir(&session_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let log_file = path.join("session.json");
if log_file.exists() {
if let Ok(metadata) = log_file.metadata() {
if let Ok(modified) = metadata.modified() {
if latest_log.is_none() || modified > latest_log.as_ref().unwrap().1 {
latest_log = Some((log_file, modified));
}
}
}
}
}
}
}
let log_file = latest_log?.0;
let content = fs::read_to_string(&log_file).ok()?;
// Parse JSON and extract the last assistant message as summary
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let messages = json.get("messages")?.as_array()?;
// Find the last assistant message
for msg in messages.iter().rev() {
if msg.get("role")?.as_str()? == "assistant" {
if let Some(content) = msg.get("content") {
if let Some(text) = content.as_str() {
// Truncate if too long
let summary = if text.len() > 500 {
format!("{}...", &text[..500])
} else {
text.to_string()
};
return Some(summary);
}
}
}
}
None
}

View File

@@ -0,0 +1,166 @@
//! Session management for studio
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Session status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionStatus {
Running,
Complete,
Failed,
}
/// A studio session representing a g3 agent run in a worktree
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Short unique identifier
pub id: String,
/// Agent name
pub agent: String,
/// When the session was created
pub created_at: DateTime<Utc>,
/// Current status
pub status: SessionStatus,
/// Process ID if running
pub pid: Option<u32>,
/// Path to the worktree
pub worktree_path: Option<PathBuf>,
}
impl Session {
/// Create a new session with a short UUID
pub fn new(agent: &str) -> Self {
// Generate a short UUID (first 8 chars of a UUID v4)
let full_uuid = Uuid::new_v4();
let short_id = full_uuid.to_string()[..8].to_string();
Self {
id: short_id,
agent: agent.to_string(),
created_at: Utc::now(),
status: SessionStatus::Running,
pid: None,
worktree_path: None,
}
}
/// Get the git branch name for this session
pub fn branch_name(&self) -> String {
format!("sessions/{}/{}", self.agent, self.id)
}
/// Get the sessions metadata directory
fn sessions_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".worktrees").join(".sessions")
}
/// Get the path to this session's metadata file
fn metadata_path(&self, repo_root: &Path) -> PathBuf {
Self::sessions_dir(repo_root).join(format!("{}.json", self.id))
}
/// Save session metadata
pub fn save(&self, repo_root: &Path, worktree_path: &Path) -> Result<()> {
let mut session = self.clone();
session.worktree_path = Some(worktree_path.to_path_buf());
let sessions_dir = Self::sessions_dir(repo_root);
fs::create_dir_all(&sessions_dir).context("Failed to create sessions directory")?;
let path = session.metadata_path(repo_root);
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Update session with PID
pub fn update_pid(&self, repo_root: &Path, pid: u32) -> 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.pid = Some(pid);
let json = serde_json::to_string_pretty(&session)?;
fs::write(&path, json).context("Failed to write session metadata")?;
Ok(())
}
/// Mark session as complete
pub fn mark_complete(&self, repo_root: &Path, success: bool) -> 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 = if success {
SessionStatus::Complete
} else {
SessionStatus::Failed
};
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));
if !path.exists() {
bail!("Session '{}' not found", session_id);
}
let content = fs::read_to_string(&path).context("Failed to read session metadata")?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// List all sessions
pub fn list_all(repo_root: &Path) -> Result<Vec<Session>> {
let sessions_dir = Self::sessions_dir(repo_root);
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<Session>(&content) {
sessions.push(session);
}
}
}
}
// Sort by creation time, newest first
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(sessions)
}
/// Delete session metadata
pub fn delete(&self, repo_root: &Path) -> Result<()> {
let path = self.metadata_path(repo_root);
if path.exists() {
fs::remove_file(&path).context("Failed to delete session metadata")?;
}
Ok(())
}
}