diff --git a/src/cli_context.rs b/src/cli_context.rs index 88e0177..f85b6b4 100644 --- a/src/cli_context.rs +++ b/src/cli_context.rs @@ -670,6 +670,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "claude"})), session_id: None, + project: None, }), go: false, }; @@ -700,6 +701,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "codex"})), session_id: None, + project: None, }), go: false, }; @@ -721,13 +723,15 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "codex"})), session_id: None, + project: None, }), go: false, }; - set_hookless_command_status(&db, "send", &ctx); + // listen is in skip list — should not change status + set_hookless_command_status(&db, "listen", &ctx); let data = db.get_instance_full("luna").unwrap().unwrap(); - assert_eq!(data.status, ST_ACTIVE); - assert_eq!(data.status_context, "tool:send"); + // Status should remain the original (active from INSERT) + assert_eq!(data.status, "active"); } #[test] @@ -741,6 +745,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "adhoc"})), session_id: None, + project: None, }), go: false, }; @@ -761,6 +766,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "claude"})), session_id: None, + project: None, }), go: false, }; @@ -787,6 +793,7 @@ mod tests { name: "sub1".into(), instance_data: Some(serde_json::json!({"tool": "claude", "parent_name": "luna"})), session_id: None, + project: None, }), go: false, }; @@ -927,6 +934,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "codex"})), session_id: None, + project: None, }), go: false, }; @@ -943,6 +951,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"tool": "claude"})), session_id: None, + project: None, }), go: false, }; diff --git a/src/commands/launch.rs b/src/commands/launch.rs index d238046..411882c 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -31,6 +31,7 @@ pub fn run(argv: &[String], flags: &GlobalFlags) -> Result { } let tag = hcom_flags.tag; + let project = hcom_flags.project.clone(); let terminal = hcom_flags.terminal; let headless = hcom_flags.headless; let pty_requested = hcom_flags.pty; @@ -177,6 +178,7 @@ pub fn run(argv: &[String], flags: &GlobalFlags) -> Result { count, args: merged_args, tag, + project, system_prompt, initial_prompt, pty: use_pty, @@ -454,6 +456,7 @@ pub(crate) fn print_launch_preview(preview: LaunchPreview<'_>) { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(crate) struct HcomLaunchFlags { pub tag: Option, + pub project: Option, pub terminal: Option, pub device: Option, pub headless: bool, @@ -597,6 +600,11 @@ pub(crate) fn extract_launch_flags(args: &[String]) -> (HcomLaunchFlags, Vec { + flags.project = Some(args[i + 1].clone()); + i += 2; + } "--terminal" if i + 1 < args.len() => { flags.terminal = Some(args[i + 1].clone()); i += 2; diff --git a/src/commands/list.rs b/src/commands/list.rs index c18866d..460ac90 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -48,6 +48,9 @@ pub struct ListArgs { /// Limit results (with --stopped) #[arg(long)] pub last: Option, + /// Filter by project (show only agents in this project) + #[arg(long)] + pub project: Option, } /// Get unread message count for a single instance. @@ -212,6 +215,26 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i } }; + // Filter by project if --project provided + let sorted_instances: Vec = if let Some(ref proj) = args.project { + let proj = proj.trim(); + if proj.is_empty() { + sorted_instances + } else { + sorted_instances + .into_iter() + .filter(|inst| { + inst.project + .as_deref() + .map(|p| p == proj) + .unwrap_or(true) + }) + .collect() + } + } else { + sorted_instances + }; + let unread_counts = get_unread_counts_batch(db, &sorted_instances); if names_output { diff --git a/src/commands/resume.rs b/src/commands/resume.rs index 2d0c97a..dc1c74f 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -521,6 +521,7 @@ fn prepare_resume_plan_from_source( count: 1, args: merged_args, tag: launch_tag, + project: None, system_prompt: effective_system_prompt, initial_prompt: fork_initial_prompt, pty: use_pty, diff --git a/src/commands/send.rs b/src/commands/send.rs index 4894f42..cb06731 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -128,6 +128,10 @@ pub struct SendArgs { #[arg(long)] pub extends: Option, + /// Project isolation: filter targets to same project + #[arg(long)] + pub project: Option, + /// Set by router: whether `--` was present in raw argv. /// Clap can't distinguish "no --" from "-- with no args", so the router sets this. #[arg(skip)] @@ -243,18 +247,20 @@ pub fn send_message( message: &str, envelope: Option<&MessageEnvelope>, explicit_targets: Option<&[String]>, + sender_project: Option<&str>, ) -> Result, String> { validate_message(message)?; // Get participating instances let rows: Vec = db .conn() - .prepare("SELECT name, tag FROM instances") + .prepare("SELECT name, tag, project FROM instances") .map_err(|e| format!("DB error: {e}"))? .query_map([], |row| { Ok(InstanceInfo { name: row.get::<_, String>(0)?, tag: row.get::<_, Option>(1)?, + project: row.get::<_, Option>(2)?, }) }) .map_err(|e| format!("DB error: {e}"))? @@ -263,7 +269,7 @@ pub fn send_message( // Compute scope and routing. Thread-only sends keep their original message // semantics; membership only affects the delivery target set. - let scope_result = compute_scope(message, &rows, explicit_targets.map(|t| t as &[String]))?; + let scope_result = compute_scope(message, &rows, explicit_targets.map(|t| t as &[String]), sender_project)?; let thread_delivery_members = if let Some(thread) = envelope.and_then(|env| env.thread.as_deref()) { if scope_result.scope == MessageScope::Broadcast { @@ -750,6 +756,7 @@ pub fn cmd_send(db: &HcomDb, args: &SendArgs, ctx: Option<&CommandContext>) -> i name: name.clone(), instance_data: None, session_id: None, + project: None, } } else if let Some(id) = ctx.and_then(|c| c.identity.as_ref()) { id.clone() @@ -892,12 +899,20 @@ pub fn cmd_send(db: &HcomDb, args: &SendArgs, ctx: Option<&CommandContext>) -> i None }; + // Resolve sender project: --project flag overrides identity data + let sender_project: Option<&str> = if let Some(ref proj) = args.project { + Some(proj.as_str()) + } else { + sender_identity.project() + }; + let delivered_to = match send_message( db, &sender_identity, &message, if has_envelope { Some(&envelope) } else { None }, targets_to_pass, + sender_project, ) { Ok(d) => d, Err(e) => { @@ -1293,56 +1308,14 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, - }; - let envelope = MessageEnvelope { - thread: Some("debate-1".into()), - ..Default::default() - }; - - let delivered = send_message( - &db, - &sender, - "hello", - Some(&envelope), - Some(&["nova".to_string(), "miso".to_string()]), - ) - .unwrap(); - assert_eq!(delivered, vec!["nova".to_string(), "miso".to_string()]); - - let members = db.get_thread_members("debate-1"); - assert_eq!( - members, - vec!["nova".to_string(), "miso".to_string(), "luna".to_string()] - ); - - let delivered = send_message(&db, &sender, "round 2", Some(&envelope), None).unwrap(); - assert_eq!(delivered, vec!["nova".to_string(), "miso".to_string()]); - - cleanup_test_db(path); - } - - #[test] - fn send_message_thread_without_members_errors() { - let (db, path) = setup_test_db(); - db.conn() - .execute( - "INSERT INTO instances (name, created_at) VALUES ('luna', 1000.0)", - [], - ) - .unwrap(); - - let sender = SenderIdentity { - kind: SenderKind::Instance, - name: "luna".into(), - instance_data: None, - session_id: None, + project: None, }; let envelope = MessageEnvelope { thread: Some("empty-thread".into()), ..Default::default() }; - let err = send_message(&db, &sender, "hello", Some(&envelope), None).unwrap_err(); + let err = send_message(&db, &sender, "hello", Some(&envelope), None, None).unwrap_err(); assert!(err.contains("has no members")); cleanup_test_db(path); @@ -1363,6 +1336,7 @@ mod tests { name: "bigboss".into(), instance_data: None, session_id: None, + project: None, }; let envelope = MessageEnvelope { thread: Some("ops".into()), @@ -1375,6 +1349,7 @@ mod tests { "hello", Some(&envelope), Some(&["nova".to_string()]), + None, ) .unwrap(); assert_eq!(delivered, vec!["nova".to_string()]); @@ -1398,6 +1373,7 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, + project: None, }; let seed_envelope = MessageEnvelope { thread: Some("ops".into()), @@ -1409,6 +1385,7 @@ mod tests { "seed", Some(&seed_envelope), Some(&["nova".to_string()]), + None, ) .unwrap(); @@ -1418,7 +1395,7 @@ mod tests { ..Default::default() }; let delivered = - send_message(&db, &sender, "status?", Some(&request_envelope), None).unwrap(); + send_message(&db, &sender, "status?", Some(&request_envelope), None, None).unwrap(); assert_eq!(delivered, vec!["nova".to_string()]); let reqwatch_count: i64 = db diff --git a/src/commands/start.rs b/src/commands/start.rs index bafd01e..4615d08 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -493,6 +493,7 @@ fn start_rebind( Some(tool), false, // background None, // tag + None, // project None, // wait_timeout None, // subagent_timeout None, // hints @@ -754,6 +755,7 @@ fn start_bare( Some(tool), false, // background None, // tag + None, // project None, // wait_timeout None, // subagent_timeout None, // hints diff --git a/src/commands/transcript.rs b/src/commands/transcript.rs index 78970fe..d3f304a 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -1342,6 +1342,7 @@ mod tests { .execute_batch( "CREATE TABLE instances ( name text, + project TEXT DEFAULT '', transcript_path text, session_id text ); diff --git a/src/config.rs b/src/config.rs index 6f8b555..8200374 100644 --- a/src/config.rs +++ b/src/config.rs @@ -82,6 +82,7 @@ impl Config { const TOML_KEY_MAP: &[(&str, &str)] = &[ ("terminal", "terminal.active"), ("tag", "launch.tag"), + ("project", "launch.project"), ("hints", "launch.hints"), ("notes", "launch.notes"), ("subagent_timeout", "launch.subagent_timeout"), @@ -111,6 +112,7 @@ const FIELD_TO_ENV: &[(&str, &str)] = &[ ("hints", "HCOM_HINTS"), ("notes", "HCOM_NOTES"), ("tag", "HCOM_TAG"), + ("project", "HCOM_PROJECT"), ("claude_args", "HCOM_CLAUDE_ARGS"), ("gemini_args", "HCOM_GEMINI_ARGS"), ("codex_args", "HCOM_CODEX_ARGS"), @@ -219,6 +221,7 @@ pub struct HcomConfig { pub hints: String, pub notes: String, pub tag: String, + pub project: String, pub claude_args: String, pub gemini_args: String, pub codex_args: String, @@ -245,6 +248,7 @@ impl Default for HcomConfig { hints: String::new(), notes: String::new(), tag: String::new(), + project: String::new(), claude_args: String::new(), gemini_args: String::new(), codex_args: String::new(), @@ -331,6 +335,14 @@ impl HcomConfig { ); } + // Validate project (alphanumeric + hyphens only) + if !self.project.is_empty() && !RE_TAG.is_match(&self.project) { + errors.insert( + "project".into(), + "project can only contain letters, numbers, and hyphens".into(), + ); + } + // Validate shell-quoted args fields for (field, value) in [ ("claude_args", &self.claude_args), @@ -391,6 +403,7 @@ impl HcomConfig { "hints" => Some(self.hints.clone()), "notes" => Some(self.notes.clone()), "tag" => Some(self.tag.clone()), + "project" => Some(self.project.clone()), "claude_args" => Some(self.claude_args.clone()), "gemini_args" => Some(self.gemini_args.clone()), "codex_args" => Some(self.codex_args.clone()), @@ -427,6 +440,7 @@ impl HcomConfig { "hints" => self.hints = value.to_string(), "notes" => self.notes = value.to_string(), "tag" => self.tag = value.to_string(), + "project" => self.project = value.to_string(), "claude_args" => self.claude_args = value.to_string(), "gemini_args" => self.gemini_args = value.to_string(), "codex_args" => self.codex_args = value.to_string(), @@ -541,6 +555,7 @@ impl HcomConfig { "hints", "notes", "tag", + "project", "claude_args", "gemini_args", "codex_args", @@ -874,6 +889,7 @@ enabled = true [launch] tag = "" +project = "" hints = "" notes = "" subagent_timeout = 30 diff --git a/src/core/bundles.rs b/src/core/bundles.rs index ffb8cc6..0f550c0 100644 --- a/src/core/bundles.rs +++ b/src/core/bundles.rs @@ -449,6 +449,7 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(get_bundle_instance_name(&id), "luna"); } @@ -460,6 +461,7 @@ mod tests { name: "user".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(get_bundle_instance_name(&id), "ext_user"); } diff --git a/src/core/helpers.rs b/src/core/helpers.rs index 41945cc..10f34bb 100644 --- a/src/core/helpers.rs +++ b/src/core/helpers.rs @@ -136,6 +136,7 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(get_bundle_instance_name(&id), "luna"); } @@ -147,6 +148,7 @@ mod tests { name: "user".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(get_bundle_instance_name(&id), "ext_user"); } @@ -158,6 +160,7 @@ mod tests { name: "hcom".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(get_bundle_instance_name(&id), "sys_hcom"); } diff --git a/src/db/events.rs b/src/db/events.rs index 1787f3e..c2ed8f0 100644 --- a/src/db/events.rs +++ b/src/db/events.rs @@ -33,6 +33,9 @@ impl HcomDb { if from == receiver { return false; } + if let Some(delivered) = json.get("delivered_to").and_then(|v| v.as_array()) { + return delivered.iter().any(|v| v.as_str() == Some(receiver)); + } let scope = json .get("scope") .and_then(|s| s.as_str()) diff --git a/src/db/instances.rs b/src/db/instances.rs index 2daf9e8..313a355 100644 --- a/src/db/instances.rs +++ b/src/db/instances.rs @@ -24,6 +24,7 @@ pub struct InstanceRow { pub parent_name: Option, pub agent_id: Option, pub tag: Option, + pub project: Option, pub last_event_id: i64, pub last_stop: i64, pub status: String, @@ -70,6 +71,9 @@ impl InstanceRow { tag: row .get::<_, Option>("tag")? .filter(|s| !s.is_empty()), + project: row + .get::<_, Option>("project")? + .filter(|s| !s.is_empty()), last_event_id: row.get::<_, Option>("last_event_id")?.unwrap_or(0), last_stop: row.get::<_, Option>("last_stop")?.unwrap_or(0), status: row @@ -132,7 +136,7 @@ impl InstanceRow { /// Get instance by name. Returns full row as JSON or None. /// Column list for instance SELECT queries. Must match instance_row_to_json index order. pub(super) const INSTANCE_COLUMNS: &str = - "name, session_id, parent_session_id, parent_name, tag, last_event_id, + "name, session_id, parent_session_id, parent_name, tag, project, last_event_id, status, status_time, status_context, status_detail, last_stop, directory, created_at, transcript_path, tcp_mode, wait_timeout, background, background_log_file, name_announced, agent_id, running_tasks, @@ -477,32 +481,33 @@ impl HcomDb { "parent_session_id": row.get::<_, Option>(2).unwrap_or(None), "parent_name": row.get::<_, Option>(3).unwrap_or(None), "tag": row.get::<_, Option>(4).unwrap_or(None), - "last_event_id": row.get::<_, i64>(5).unwrap_or(0), - "status": row.get::<_, String>(6).unwrap_or_default(), - "status_time": row.get::<_, i64>(7).unwrap_or(0), - "status_context": row.get::<_, String>(8).unwrap_or_default(), - "status_detail": row.get::<_, String>(9).unwrap_or_default(), - "last_stop": row.get::<_, i64>(10).unwrap_or(0), - "directory": row.get::<_, Option>(11).unwrap_or(None), - "created_at": row.get::<_, f64>(12).unwrap_or(0.0), - "transcript_path": row.get::<_, String>(13).unwrap_or_default(), - "tcp_mode": row.get::<_, i64>(14).unwrap_or(0), - "wait_timeout": row.get::<_, i64>(15).unwrap_or(86400), - "background": row.get::<_, i64>(16).unwrap_or(0), - "background_log_file": row.get::<_, String>(17).unwrap_or_default(), - "name_announced": row.get::<_, i64>(18).unwrap_or(0), - "agent_id": row.get::<_, Option>(19).unwrap_or(None), - "running_tasks": row.get::<_, String>(20).unwrap_or_default(), - "origin_device_id": row.get::<_, String>(21).unwrap_or_default(), - "hints": row.get::<_, String>(22).unwrap_or_default(), - "subagent_timeout": row.get::<_, Option>(23).unwrap_or(None), - "tool": row.get::<_, String>(24).unwrap_or_default(), - "launch_args": row.get::<_, String>(25).unwrap_or_default(), - "terminal_preset_requested": row.get::<_, String>(26).unwrap_or_default(), - "terminal_preset_effective": row.get::<_, String>(27).unwrap_or_default(), - "idle_since": row.get::<_, String>(28).unwrap_or_default(), - "pid": row.get::<_, Option>(29).unwrap_or(None), - "launch_context": row.get::<_, String>(30).unwrap_or_default(), + "project": row.get::<_, Option>(5).unwrap_or(None), + "last_event_id": row.get::<_, i64>(6).unwrap_or(0), + "status": row.get::<_, String>(7).unwrap_or_default(), + "status_time": row.get::<_, i64>(8).unwrap_or(0), + "status_context": row.get::<_, String>(9).unwrap_or_default(), + "status_detail": row.get::<_, String>(10).unwrap_or_default(), + "last_stop": row.get::<_, i64>(11).unwrap_or(0), + "directory": row.get::<_, Option>(12).unwrap_or(None), + "created_at": row.get::<_, f64>(13).unwrap_or(0.0), + "transcript_path": row.get::<_, String>(14).unwrap_or_default(), + "tcp_mode": row.get::<_, i64>(15).unwrap_or(0), + "wait_timeout": row.get::<_, i64>(16).unwrap_or(86400), + "background": row.get::<_, i64>(17).unwrap_or(0), + "background_log_file": row.get::<_, String>(18).unwrap_or_default(), + "name_announced": row.get::<_, i64>(19).unwrap_or(0), + "agent_id": row.get::<_, Option>(20).unwrap_or(None), + "running_tasks": row.get::<_, String>(21).unwrap_or_default(), + "origin_device_id": row.get::<_, String>(22).unwrap_or_default(), + "hints": row.get::<_, String>(23).unwrap_or_default(), + "subagent_timeout": row.get::<_, Option>(24).unwrap_or(None), + "tool": row.get::<_, String>(25).unwrap_or_default(), + "launch_args": row.get::<_, String>(26).unwrap_or_default(), + "terminal_preset_requested": row.get::<_, String>(27).unwrap_or_default(), + "terminal_preset_effective": row.get::<_, String>(28).unwrap_or_default(), + "idle_since": row.get::<_, String>(29).unwrap_or_default(), + "pid": row.get::<_, Option>(30).unwrap_or(None), + "launch_context": row.get::<_, String>(31).unwrap_or_default(), })) } @@ -747,6 +752,7 @@ impl HcomDb { "parent_name", "agent_id", "tag", + "project", "last_event_id", "last_stop", "status", diff --git a/src/db/mod.rs b/src/db/mod.rs index f4a7eb5..d56957a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -35,16 +35,22 @@ pub use instances::InstanceRow; pub use instances::InstanceStatus; /// Schema version - bump on any schema change. -const SCHEMA_VERSION: i32 = 17; +const SCHEMA_VERSION: i32 = 18; pub const DEV_ROOT_KV_KEY: &str = "config:dev_root"; -const MIGRATIONS: &[(i32, &str)] = &[( - 17, - "ALTER TABLE instances ADD COLUMN terminal_preset_requested TEXT DEFAULT ''; - ALTER TABLE instances ADD COLUMN terminal_preset_effective TEXT DEFAULT ''; - UPDATE instances - SET terminal_preset_effective = json_extract(launch_context, '$.terminal_preset') - WHERE launch_context != '' AND json_valid(launch_context) AND json_extract(launch_context, '$.terminal_preset') IS NOT NULL;", -)]; +const MIGRATIONS: &[(i32, &str)] = &[ + ( + 17, + "ALTER TABLE instances ADD COLUMN terminal_preset_requested TEXT DEFAULT ''; + ALTER TABLE instances ADD COLUMN terminal_preset_effective TEXT DEFAULT ''; + UPDATE instances + SET terminal_preset_effective = json_extract(launch_context, '$.terminal_preset') + WHERE launch_context != '' AND json_valid(launch_context) AND json_extract(launch_context, '$.terminal_preset') IS NOT NULL;", + ), + ( + 18, + "ALTER TABLE instances ADD COLUMN project TEXT DEFAULT '';", + ), +]; /// Schema compatibility check result enum SchemaCompat { @@ -221,6 +227,7 @@ impl HcomDb { parent_session_id TEXT, parent_name TEXT, tag TEXT, + project TEXT DEFAULT '', last_event_id INTEGER DEFAULT 0, status TEXT DEFAULT 'active', status_time INTEGER DEFAULT 0, @@ -527,6 +534,7 @@ impl HcomDb { .collect(); let required = [ "tool", + "project", "terminal_preset_requested", "terminal_preset_effective", ]; @@ -803,6 +811,7 @@ pub(super) mod tests { directory TEXT, parent_name TEXT, tag TEXT, + project TEXT DEFAULT '', wait_timeout INTEGER, subagent_timeout INTEGER, hints TEXT, @@ -1144,6 +1153,66 @@ pub(super) mod tests { cleanup_test_db(db_path); } + #[test] + fn test_ensure_schema_migrates_v17_to_v18_in_place() { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(2600); + + let temp_dir = std::env::temp_dir(); + let test_id = COUNTER.fetch_add(1, Ordering::Relaxed); + let db_path = temp_dir.join(format!( + "test_hcom_migrate_{}_{}.db", + std::process::id(), + test_id + )); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE events (id INTEGER PRIMARY KEY, timestamp TEXT, type TEXT, instance TEXT, data TEXT); + CREATE TABLE instances ( + name TEXT PRIMARY KEY, + tool TEXT DEFAULT 'claude', + created_at REAL NOT NULL, + terminal_preset_requested TEXT DEFAULT '', + terminal_preset_effective TEXT DEFAULT '', + launch_context TEXT DEFAULT '' + ); + CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE notify_endpoints (instance TEXT, kind TEXT, port INTEGER, updated_at REAL, PRIMARY KEY(instance, kind)); + CREATE TABLE session_bindings (session_id TEXT PRIMARY KEY, instance_name TEXT NOT NULL, created_at REAL NOT NULL); + PRAGMA user_version = 17;", + ) + .unwrap(); + conn.execute( + "INSERT INTO instances (name, tool, created_at) VALUES (?1, ?2, ?3)", + rusqlite::params!["luna", "claude", 1.0f64], + ) + .unwrap(); + } + + let mut db = HcomDb::open_raw(&db_path).unwrap(); + db.ensure_schema().unwrap(); + + let version: i32 = db + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + + let project: String = db + .conn + .query_row( + "SELECT project FROM instances WHERE name = ?", + params!["luna"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(project, ""); + + cleanup_test_db(db_path); + } + #[test] fn test_ensure_schema_column_guard() { use std::sync::atomic::{AtomicU64, Ordering}; @@ -1210,9 +1279,9 @@ pub(super) mod tests { test_id )); - // Simulate the bug: create a v16-style DB but stamp it as v17 + // Simulate the bug: create a v17-style DB but stamp it as v18 // (this is what init_db() did — CREATE IF NOT EXISTS is a no-op on - // existing tables, then it unconditionally set user_version = 17) + // existing tables, then it unconditionally set user_version = 18) { let conn = Connection::open(&db_path).unwrap(); conn.execute_batch( @@ -1244,6 +1313,8 @@ pub(super) mod tests { subagent_timeout INTEGER, tool TEXT DEFAULT 'claude', launch_args TEXT DEFAULT '', + terminal_preset_requested TEXT DEFAULT '', + terminal_preset_effective TEXT DEFAULT '', idle_since TEXT DEFAULT '', pid INTEGER DEFAULT NULL, launch_context TEXT DEFAULT '' @@ -1252,7 +1323,7 @@ pub(super) mod tests { CREATE TABLE notify_endpoints (instance TEXT NOT NULL, kind TEXT NOT NULL, port INTEGER NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY(instance, kind)); CREATE TABLE session_bindings (session_id TEXT PRIMARY KEY, instance_name TEXT NOT NULL, created_at REAL NOT NULL); CREATE TABLE process_bindings (process_id TEXT PRIMARY KEY, session_id TEXT, instance_name TEXT, updated_at REAL NOT NULL); - PRAGMA user_version = 17;", + PRAGMA user_version = 18;", ) .unwrap(); // Insert test data that should survive the repair @@ -1263,7 +1334,7 @@ pub(super) mod tests { .unwrap(); } - // Verify columns are missing before repair + // Verify project column is missing before repair { let conn = Connection::open(&db_path).unwrap(); let cols: Vec = conn @@ -1274,8 +1345,8 @@ pub(super) mod tests { .filter_map(|r| r.ok()) .collect(); assert!( - !cols.contains(&"terminal_preset_requested".to_string()), - "column should be missing before repair" + !cols.contains(&"project".to_string()), + "project column should be missing before repair" ); } @@ -1299,24 +1370,16 @@ pub(super) mod tests { .filter_map(|r| r.ok()) .collect(); assert!( - cols.contains(&"terminal_preset_requested".to_string()), - "terminal_preset_requested column should exist after repair" - ); - assert!( - cols.contains(&"terminal_preset_effective".to_string()), - "terminal_preset_effective column should exist after repair" + cols.contains(&"project".to_string()), + "project column should exist after repair" ); // Test data should have survived (not archived) - let name: String = db + let count: i64 = db .conn - .query_row( - "SELECT name FROM instances WHERE name = 'luna'", - [], - |row| row.get(0), - ) + .query_row("SELECT COUNT(*) FROM instances", [], |row| row.get(0)) .unwrap(); - assert_eq!(name, "luna"); + assert_eq!(count, 1, "data should not have been archived"); cleanup_test_db(db_path); } diff --git a/src/identity.rs b/src/identity.rs index 4d8f292..e038bec 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -24,6 +24,14 @@ static DANGEROUS_CHARS_WITH_AT: LazyLock = /// Commands that require a resolved identity to operate. const REQUIRE_IDENTITY: &[&str] = &["send", "listen"]; +/// Extract project from instance data JSON. +fn extract_project(data: &serde_json::Value) -> Option { + data.get("project") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + /// Check if value looks like a UUID (agent_id format). pub fn looks_like_uuid(value: &str) -> bool { UUID_PATTERN.is_match(value) @@ -226,7 +234,8 @@ pub fn resolve_from_name(db: &HcomDb, name: &str) -> Result Result { @@ -425,7 +437,8 @@ fn resolve_identity_with_expectation( kind: SenderKind::Instance, name: final_name, session_id: sid, - instance_data: Some(final_data), + instance_data: Some(final_data.clone()), + project: extract_project(&final_data), }); } None => { @@ -788,6 +801,7 @@ mod tests { name: "nova".to_string(), instance_data: None, session_id: Some("sess-2".to_string()), + project: None, }) }; @@ -939,6 +953,7 @@ mod tests { parent_name: None, agent_id: None, tag: None, + project: None, last_event_id: 0, last_stop: 0, status: String::from("inactive"), diff --git a/src/instance_binding.rs b/src/instance_binding.rs index 6f93150..5ad31ee 100644 --- a/src/instance_binding.rs +++ b/src/instance_binding.rs @@ -450,6 +450,7 @@ pub fn initialize_instance_in_position_file( tool: Option<&str>, background: bool, tag: Option<&str>, + project: Option<&str>, wait_timeout: Option, subagent_timeout: Option, hints: Option<&str>, @@ -488,6 +489,9 @@ pub fn initialize_instance_in_position_file( if let Some(t) = tag { updates.insert("tag".into(), serde_json::json!(t)); } + if let Some(p) = project { + updates.insert("project".into(), serde_json::json!(p)); + } if background { updates.insert("background".into(), serde_json::json!(1)); } @@ -563,6 +567,11 @@ pub fn initialize_instance_in_position_file( } } + data.insert("project".into(), serde_json::Value::Null); + if let Some(p) = project { + data.insert("project".into(), serde_json::json!(p)); + } + if let Some(wt) = wait_timeout { data.insert("wait_timeout".into(), serde_json::json!(wt)); } @@ -642,6 +651,7 @@ pub fn create_orphaned_pty_identity( None, None, None, + None, ); if !success { @@ -779,8 +789,9 @@ mod tests { session_id TEXT UNIQUE, parent_session_id TEXT, parent_name TEXT, - tag TEXT, - last_event_id INTEGER DEFAULT 0, + tag TEXT, + project TEXT DEFAULT '', + last_event_id INTEGER DEFAULT 0, status TEXT DEFAULT 'active', status_time INTEGER DEFAULT 0, status_context TEXT DEFAULT '', diff --git a/src/instance_lifecycle.rs b/src/instance_lifecycle.rs index d80cb64..6841b21 100644 --- a/src/instance_lifecycle.rs +++ b/src/instance_lifecycle.rs @@ -709,8 +709,9 @@ mod tests { session_id TEXT UNIQUE, parent_session_id TEXT, parent_name TEXT, - tag TEXT, - last_event_id INTEGER DEFAULT 0, + tag TEXT, + project TEXT DEFAULT '', + last_event_id INTEGER DEFAULT 0, status TEXT DEFAULT 'active', status_time INTEGER DEFAULT 0, status_context TEXT DEFAULT '', @@ -784,6 +785,7 @@ mod tests { parent_name: None, agent_id: None, tag: None, + project: None, last_event_id: 0, last_stop: 0, status: ST_INACTIVE.into(), diff --git a/src/instances.rs b/src/instances.rs index d2a34d1..7cb1cbb 100644 --- a/src/instances.rs +++ b/src/instances.rs @@ -146,8 +146,9 @@ mod tests { session_id TEXT UNIQUE, parent_session_id TEXT, parent_name TEXT, - tag TEXT, - last_event_id INTEGER DEFAULT 0, + tag TEXT, + project TEXT DEFAULT '', + last_event_id INTEGER DEFAULT 0, status TEXT DEFAULT 'active', status_time INTEGER DEFAULT 0, status_context TEXT DEFAULT '', @@ -355,6 +356,7 @@ mod tests { parent_name: None, agent_id: None, tag: None, + project: None, last_event_id: 0, last_stop: 0, status: ST_INACTIVE.into(), diff --git a/src/launcher.rs b/src/launcher.rs index 84199d3..048a4d8 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -116,6 +116,7 @@ pub struct LaunchParams { pub count: usize, pub args: Vec, pub tag: Option, + pub project: Option, pub system_prompt: Option, pub initial_prompt: Option, pub pty: bool, @@ -138,6 +139,7 @@ impl Default for LaunchParams { count: 1, args: Vec::new(), tag: None, + project: None, system_prompt: None, initial_prompt: None, pty: false, @@ -805,6 +807,11 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { default }; + // Project env var + if let Some(ref project) = params.project { + base_env.insert("HCOM_PROJECT".to_string(), project.clone()); + } + // Explicit name validation if let Some(ref name) = params.name { if params.count > 1 { @@ -978,6 +985,7 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { } else { Some(effective_tag.as_str()) }, + params.project.as_deref(), None, // wait_timeout None, // subagent_timeout None, // hints diff --git a/src/messages.rs b/src/messages.rs index 6249d50..1a3a14e 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -108,6 +108,7 @@ pub struct ReadReceipt { pub struct InstanceInfo { pub name: String, pub tag: Option, + pub project: Option, } impl InstanceInfo { @@ -288,7 +289,28 @@ pub fn compute_scope( message: &str, enabled_instances: &[InstanceInfo], explicit_targets: Option<&[String]>, + sender_project: Option<&str>, ) -> Result { + // Filter instances by project isolation + let enabled_instances: Vec<&InstanceInfo> = if let Some(proj) = sender_project { + let proj = proj.trim(); + if proj.is_empty() { + enabled_instances.iter().collect() + } else { + enabled_instances + .iter() + .filter(|inst| { + inst.project + .as_deref() + .map(|p| p == proj) + .unwrap_or(true) // include instances with no project + }) + .collect() + } + } else { + enabled_instances.iter().collect() + }; + // Build full name lookup: {full_name: base_name} let mut full_to_base: HashMap = HashMap::new(); let mut full_names: Vec = Vec::new(); @@ -1102,13 +1124,14 @@ mod tests { InstanceInfo { name: name.to_string(), tag: tag.map(|t| t.to_string()), + project: None, } } #[test] fn test_compute_scope_broadcast() { let instances = vec![info("luna", None), info("nova", None)]; - let result = compute_scope("hello everyone", &instances, None).unwrap(); + let result = compute_scope("hello everyone", &instances, None, None).unwrap(); assert_eq!(result.scope, MessageScope::Broadcast); assert!(result.mentions.is_empty()); } @@ -1116,7 +1139,7 @@ mod tests { #[test] fn test_compute_scope_mention_in_text() { let instances = vec![info("luna", None), info("nova", None)]; - let result = compute_scope("hey @luna fix this", &instances, None).unwrap(); + let result = compute_scope("hey @luna fix this", &instances, None, None).unwrap(); assert_eq!(result.scope, MessageScope::Mentions); assert_eq!(result.mentions, vec!["luna"]); } @@ -1125,7 +1148,7 @@ mod tests { fn test_compute_scope_explicit_targets() { let instances = vec![info("luna", None), info("nova", None)]; let targets = vec!["luna".to_string()]; - let result = compute_scope("fix this", &instances, Some(&targets)).unwrap(); + let result = compute_scope("fix this", &instances, Some(&targets), None).unwrap(); assert_eq!(result.scope, MessageScope::Mentions); assert_eq!(result.mentions, vec!["luna"]); } @@ -1134,7 +1157,7 @@ mod tests { fn test_compute_scope_explicit_empty_broadcast() { let instances = vec![info("luna", None)]; let targets: Vec = vec![]; - let result = compute_scope("hello", &instances, Some(&targets)).unwrap(); + let result = compute_scope("hello", &instances, Some(&targets), None).unwrap(); assert_eq!(result.scope, MessageScope::Broadcast); } @@ -1142,7 +1165,7 @@ mod tests { fn test_compute_scope_unknown_target_fails() { let instances = vec![info("luna", None)]; let targets = vec!["nonexistent".to_string()]; - let result = compute_scope("hello", &instances, Some(&targets)); + let result = compute_scope("hello", &instances, Some(&targets), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("non-existent or stopped")); } @@ -1153,7 +1176,7 @@ mod tests { // should point users at the right form instead of just listing 30 names. let instances = vec![info("zeli:ZOME", None), info("luna", None)]; let targets = vec!["zeli".to_string()]; - let err = compute_scope("hello", &instances, Some(&targets)).unwrap_err(); + let err = compute_scope("hello", &instances, Some(&targets), None).unwrap_err(); assert!(err.contains("@zeli"), "got: {err}"); assert!(err.contains("Did you mean: @zeli:ZOME"), "got: {err}"); } @@ -1162,21 +1185,21 @@ mod tests { fn test_compute_scope_no_suggestion_when_no_remote_match() { let instances = vec![info("luna", None), info("nova", None)]; let targets = vec!["zeli".to_string()]; - let err = compute_scope("hello", &instances, Some(&targets)).unwrap_err(); + let err = compute_scope("hello", &instances, Some(&targets), None).unwrap_err(); assert!(!err.contains("Did you mean"), "got: {err}"); } #[test] fn test_compute_scope_unknown_mention_fails() { let instances = vec![info("luna", None)]; - let result = compute_scope("hey @nonexistent fix this", &instances, None); + let result = compute_scope("hey @nonexistent fix this", &instances, None, None); assert!(result.is_err()); } #[test] fn test_compute_scope_system_mention_fails() { let instances = vec![info("luna", None)]; - let result = compute_scope("hey @[hcom-events]", &instances, None); + let result = compute_scope("hey @[hcom-events]", &instances, None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("System notifications")); } @@ -1184,7 +1207,7 @@ mod tests { #[test] fn test_compute_scope_literal_mention_fails() { let instances = vec![info("luna", None)]; - let result = compute_scope("use @mention to target", &instances, None); + let result = compute_scope("use @mention to target", &instances, None, None); assert!(result.is_err()); assert!( result @@ -1197,7 +1220,7 @@ mod tests { fn test_compute_scope_tagged_instances() { let instances = vec![info("luna", Some("api")), info("nova", Some("api"))]; let targets = vec!["api-".to_string()]; - let result = compute_scope("hello", &instances, Some(&targets)).unwrap(); + let result = compute_scope("hello", &instances, Some(&targets), None).unwrap(); assert_eq!(result.scope, MessageScope::Mentions); assert!(result.mentions.contains(&"luna".to_string())); assert!(result.mentions.contains(&"nova".to_string())); @@ -1208,7 +1231,7 @@ mod tests { let instances = vec![info("luna", Some("api"))]; // Both api-luna and luna resolve to the same instance let targets = vec!["api-luna".to_string(), "luna".to_string()]; - let result = compute_scope("hello", &instances, Some(&targets)).unwrap(); + let result = compute_scope("hello", &instances, Some(&targets), None).unwrap(); assert_eq!(result.mentions.len(), 1); assert_eq!(result.mentions[0], "luna"); } diff --git a/src/relay/control.rs b/src/relay/control.rs index 0a63f65..7d249f3 100644 --- a/src/relay/control.rs +++ b/src/relay/control.rs @@ -633,6 +633,7 @@ struct RemoteLaunchRequest { count: usize, args: Vec, tag: Option, + project: Option, launcher: Option, system_prompt: Option, initial_prompt: Option, @@ -653,6 +654,7 @@ impl RemoteLaunchRequest { count, args: string_list_param(params, "args"), tag: optional_param(params, "tag").map(ToString::to_string), + project: optional_param(params, "project").map(ToString::to_string), launcher: optional_param(params, "launcher").map(ToString::to_string), system_prompt: optional_param(params, "system_prompt").map(ToString::to_string), initial_prompt: optional_param(params, "initial_prompt").map(ToString::to_string), @@ -737,6 +739,7 @@ fn handle_remote_launch( count: request.count, args: prepared.args, tag: request.tag, + project: request.project.clone(), system_prompt: request.system_prompt, initial_prompt: request.initial_prompt, pty: prepared.pty, diff --git a/src/shared/identity.rs b/src/shared/identity.rs index 6213ddf..178eff6 100644 --- a/src/shared/identity.rs +++ b/src/shared/identity.rs @@ -11,6 +11,8 @@ pub struct SenderIdentity { pub instance_data: Option, /// Claude session ID for transcript binding. pub session_id: Option, + /// Project isolation group (None = no isolation). + pub project: Option, } /// Sender identity kind — determines routing rules. @@ -47,6 +49,12 @@ impl SenderIdentity { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) } + + /// Project name for isolation filtering. + /// Returns None if the instance has no project (broadcast to all). + pub fn project(&self) -> Option<&str> { + self.project.as_deref().filter(|p| !p.is_empty()) + } } /// Resolved identity context for a single CLI invocation. @@ -71,6 +79,7 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, + project: None, }; assert!(!instance.broadcasts()); @@ -79,6 +88,7 @@ mod tests { name: "user".into(), instance_data: None, session_id: None, + project: None, }; assert!(external.broadcasts()); @@ -87,6 +97,7 @@ mod tests { name: "hcom".into(), instance_data: None, session_id: None, + project: None, }; assert!(system.broadcasts()); } @@ -98,6 +109,7 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({"session_id": "sess-123"})), session_id: None, + project: None, }; assert_eq!(parent.group_id(), Some("sess-123")); @@ -109,6 +121,7 @@ mod tests { "parent_session_id": "parent-sess" })), session_id: None, + project: None, }; assert_eq!(subagent.group_id(), Some("parent-sess")); @@ -117,6 +130,7 @@ mod tests { name: "luna".into(), instance_data: None, session_id: None, + project: None, }; assert_eq!(no_data.group_id(), None); @@ -125,7 +139,38 @@ mod tests { name: "luna".into(), instance_data: Some(serde_json::json!({})), session_id: None, + project: None, }; assert_eq!(empty.group_id(), None); } + + #[test] + fn test_sender_identity_project() { + let no_project = SenderIdentity { + kind: SenderKind::Instance, + name: "luna".into(), + instance_data: None, + session_id: None, + project: None, + }; + assert_eq!(no_project.project(), None); + + let empty_project = SenderIdentity { + kind: SenderKind::Instance, + name: "luna".into(), + instance_data: None, + session_id: None, + project: Some(String::new()), + }; + assert_eq!(empty_project.project(), None); + + let with_project = SenderIdentity { + kind: SenderKind::Instance, + name: "luna".into(), + instance_data: None, + session_id: None, + project: Some("myproj".into()), + }; + assert_eq!(with_project.project(), Some("myproj")); + } } diff --git a/src/tui/actions.rs b/src/tui/actions.rs index a0efee9..0dfaf9e 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -179,6 +179,20 @@ impl App { } other => self.ui.flash = Some(rpc_error_flash("Tag failed", other)), }, + RpcOp::Project { name, project } => match result.result { + Ok(resp) if resp.ok() => { + if let Some(agent) = self.data.agents.iter_mut().find(|a| a.name == name) { + agent.project = project.clone(); + } + self.ui.flash = if project.is_empty() { + Some(Flash::new("Project cleared".into(), Theme::flash_info())) + } else { + Some(Flash::new(format!("Project set to {}", project), Theme::flash_ok())) + }; + self.reload_data(); + } + other => self.ui.flash = Some(rpc_error_flash("Project set failed", other)), + }, RpcOp::Launch { tool, count, .. } => match result.result { Ok(resp) if resp.ok() => { self.ui.flash = Some(Flash::new( diff --git a/src/tui/app.rs b/src/tui/app.rs index 57da429..07ba0d6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -76,6 +76,7 @@ impl App { overlay: None, pending_eject_cmd: false, term_width: 80, + project_filter: None, }, } } @@ -368,6 +369,7 @@ mod tests { has_tcp: true, directory: String::new(), tag: String::new(), + project: String::new(), unread: 0, last_event_id: None, device_name: None, @@ -416,6 +418,7 @@ mod tests { count: 1, options_cursor: None, tag: String::new(), + project: String::new(), headless: false, terminal: 0, terminal_presets: vec!["default".into()], @@ -449,6 +452,7 @@ mod tests { overlay: None, pending_eject_cmd: false, term_width: 80, + project_filter: None, }, ejector: Ejector::new(), source: Box::new(NullSource), diff --git a/src/tui/db.rs b/src/tui/db.rs index 2633434..646ff20 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -261,7 +261,7 @@ fn load_instances(conn: &Connection, device_uuid: &str, now: f64) -> (Vec let mut stmt = match conn.prepare( "SELECT name, tool, status, status_context, status_detail, created_at, status_time, last_stop, tcp_mode, - directory, tag, last_event_id, origin_device_id, + directory, tag, project, last_event_id, origin_device_id, pid, session_id, background, terminal_preset_effective FROM instances ORDER BY created_at DESC", ) { @@ -282,12 +282,13 @@ fn load_instances(conn: &Connection, device_uuid: &str, now: f64) -> (Vec row.get::<_, Option>(8)?, // tcp_mode row.get::<_, Option>(9)?, // directory row.get::<_, Option>(10)?, // tag - row.get::<_, Option>(11)?, // last_event_id - row.get::<_, Option>(12)?, // origin_device_id - row.get::<_, Option>(13)?, // pid - row.get::<_, Option>(14)?, // session_id - row.get::<_, Option>(15)?, // background (headless) - row.get::<_, Option>(16)?, // terminal_preset_effective + row.get::<_, Option>(11)?, // project + row.get::<_, Option>(12)?, // last_event_id + row.get::<_, Option>(13)?, // origin_device_id + row.get::<_, Option>(14)?, // pid + row.get::<_, Option>(15)?, // session_id + row.get::<_, Option>(16)?, // background (headless) + row.get::<_, Option>(17)?, // terminal_preset_effective )) }) { Ok(r) => r, @@ -314,6 +315,7 @@ fn load_instances(conn: &Connection, device_uuid: &str, now: f64) -> (Vec tcp_mode, directory, tag, + project, last_event_id, origin_device_id, pid, @@ -381,6 +383,7 @@ fn load_instances(conn: &Connection, device_uuid: &str, now: f64) -> (Vec has_tcp, directory, tag: tag.unwrap_or_default(), + project: project.unwrap_or_default(), unread: 0, // computed separately last_event_id: last_event_id.map(|id| id as u64), device_name, @@ -597,6 +600,7 @@ fn load_stopped(conn: &Connection, now: f64, max_age_secs: Option) -> Vec) -> Vec f64 { pub struct LaunchDefaults { pub terminal: String, pub tag: String, + pub project: String, } impl Default for LaunchDefaults { @@ -1155,6 +1161,7 @@ impl Default for LaunchDefaults { Self { terminal: "default".into(), tag: String::new(), + project: String::new(), } } } @@ -1192,7 +1199,15 @@ pub fn read_launch_defaults() -> LaunchDefaults { .unwrap_or("") .to_string(); - LaunchDefaults { terminal, tag } + let project = table + .get("launch") + .and_then(|v| v.as_table()) + .and_then(|t| t.get("project")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + LaunchDefaults { terminal, tag, project } } // ── Dynamic terminal preset detection ──────────────────────────── @@ -1423,7 +1438,8 @@ mod tests { timestamp TEXT NOT NULL ); CREATE TABLE instances ( - name TEXT PRIMARY KEY + name TEXT PRIMARY KEY, + project TEXT DEFAULT '' ); ", ) @@ -1444,6 +1460,7 @@ mod tests { has_tcp: false, directory: String::new(), tag: String::new(), + project: String::new(), unread: 0, last_event_id: Some(last_event_id), device_name: None, @@ -1582,7 +1599,8 @@ mod tests { tcp_mode INTEGER, directory TEXT, tag TEXT, - last_event_id INTEGER, + project TEXT DEFAULT '', + last_event_id INTEGER, origin_device_id TEXT, pid INTEGER, session_id TEXT, diff --git a/src/tui/input.rs b/src/tui/input.rs index a2e0e0b..167d0b3 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -415,7 +415,6 @@ impl App { KeyCode::Char('t') => { let names = self.resolve_targets(); if !names.is_empty() { - // Pre-fill with common tag if all targets share one let tags: std::collections::HashSet<&str> = names .iter() .filter_map(|n| self.data.agents.iter().find(|a| a.name == *n)) @@ -429,6 +428,22 @@ impl App { self.ui.overlay = Some(Overlay::with(OverlayKind::Tag, names, common_tag)); } } + KeyCode::Char('p') => { + let names = self.resolve_targets(); + if !names.is_empty() { + let projects: std::collections::HashSet<&str> = names + .iter() + .filter_map(|n| self.data.agents.iter().find(|a| a.name == *n)) + .map(|a| a.project.as_str()) + .collect(); + let common_project = if projects.len() == 1 { + projects.into_iter().next().unwrap().to_string() + } else { + String::new() + }; + self.ui.overlay = Some(Overlay::with(OverlayKind::Project, names, common_project)); + } + } // COMPOSE ENTRY KeyCode::Char('m') => { @@ -452,6 +467,14 @@ impl App { self.ui.input_cursor = 0; } + KeyCode::Char('F') => { + let current = self.ui.project_filter.clone().unwrap_or_default(); + let cursor = current.len(); + self.ui.overlay = Some(Overlay::new(OverlayKind::ProjectFilter)); + self.ui.overlay.as_mut().unwrap().input = current; + self.ui.overlay.as_mut().unwrap().cursor = cursor; + } + // OVERLAYS KeyCode::Char('/') => { self.ui.overlay = Some(Overlay::new(OverlayKind::Search)); @@ -734,6 +757,37 @@ impl App { } } } + OverlayKind::ProjectFilter => { + self.ui.project_filter = if overlay.input.trim().is_empty() { + None + } else { + Some(overlay.input.trim().to_string()) + }; + } + OverlayKind::Project => { + let project = overlay.input.trim().to_string(); + let targets = overlay.targets; + if !targets.is_empty() { + for name in &targets { + if let Err(e) = self.enqueue_rpc(RpcOp::Project { + name: name.clone(), + project: project.clone(), + }) { + self.ui.flash = + Some(Flash::new(format!("Project set failed: {}", e), Theme::flash_err())); + break; + } + } + if self.ui.flash.is_none() { + let label = if targets.len() == 1 { + format!("Setting project for {}", targets[0]) + } else { + format!("Setting project for {} agents", targets.len()) + }; + self.ui.flash = Some(Flash::new(label, Theme::flash_info())); + } + } + } } } @@ -750,6 +804,8 @@ impl App { if had_search { self.ui.trigger_inline_replay(); } + } else if overlay.kind == OverlayKind::ProjectFilter { + self.ui.project_filter = None; } } @@ -1041,6 +1097,7 @@ impl App { let tool = self.ui.launch.tool; let count = self.ui.launch.count; let tag = self.ui.launch.tag.clone(); + let project = self.ui.launch.project.clone(); let headless = self.ui.launch.headless; let terminal = self .ui @@ -1055,6 +1112,7 @@ impl App { tool, count, tag, + project, headless, terminal: terminal.into(), prompt, diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5156a90..8583d53 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -318,6 +318,7 @@ pub mod test_helpers { has_tcp: true, directory: "/tmp".into(), tag: String::new(), + project: String::new(), unread: 0, last_event_id: None, device_name: None, diff --git a/src/tui/model.rs b/src/tui/model.rs index 117e0f0..e4f63b6 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -131,6 +131,7 @@ pub struct Agent { pub has_tcp: bool, pub directory: String, pub tag: String, + pub project: String, pub unread: usize, pub last_event_id: Option, pub device_name: Option, @@ -355,6 +356,8 @@ pub enum OverlayKind { Search, Command, Tag, + Project, + ProjectFilter, } pub struct Overlay { @@ -484,6 +487,7 @@ pub enum LaunchField { Tool, Count, Tag, + Project, Headless, Terminal, } @@ -493,6 +497,7 @@ pub struct LaunchState { pub count: u8, pub options_cursor: Option, pub tag: String, + pub project: String, pub headless: bool, pub terminal: usize, pub terminal_presets: Vec, @@ -521,6 +526,7 @@ impl LaunchState { count: 1, options_cursor: None, tag: defaults.tag, + project: defaults.project, headless: false, terminal: terminal_idx, terminal_presets: presets, @@ -533,10 +539,10 @@ impl LaunchState { /// Height of the inline panel. pub fn panel_height(&self) -> u16 { if self.tool == Tool::Claude { - // sep + tool + count + tag + headless + terminal + // tool + count + tag + project + headless + terminal 6 } else { - // sep + tool + count + tag + terminal + // tool + count + tag + project + terminal 5 } } @@ -548,6 +554,7 @@ impl LaunchState { LaunchField::Tool, LaunchField::Count, LaunchField::Tag, + LaunchField::Project, LaunchField::Headless, LaunchField::Terminal, ] @@ -556,6 +563,7 @@ impl LaunchState { LaunchField::Tool, LaunchField::Count, LaunchField::Tag, + LaunchField::Project, LaunchField::Terminal, ] } @@ -611,7 +619,7 @@ impl LaunchState { self.auto_edit_text_field(); } - /// Auto-enter editing mode when landing on a text field (Tag). + /// Auto-enter editing mode when landing on a text field (Tag, Project). fn auto_edit_text_field(&mut self) { if self.is_text_field() && self.editing.is_none() { self.start_editing(); @@ -659,7 +667,10 @@ impl LaunchState { } pub fn is_text_field(&self) -> bool { - matches!(self.options_cursor, Some(LaunchField::Tag)) + matches!( + self.options_cursor, + Some(LaunchField::Tag) | Some(LaunchField::Project) + ) } pub fn start_editing(&mut self) { @@ -691,20 +702,25 @@ impl LaunchState { } pub fn edit_cursor_left(&mut self) { - if let Some(LaunchField::Tag) = self.editing { - cursor_left(&self.tag, &mut self.edit_cursor); + match self.editing { + Some(LaunchField::Tag) => cursor_left(&self.tag, &mut self.edit_cursor), + Some(LaunchField::Project) => cursor_left(&self.project, &mut self.edit_cursor), + _ => {} } } pub fn edit_cursor_right(&mut self) { - if let Some(LaunchField::Tag) = self.editing { - cursor_right(&self.tag, &mut self.edit_cursor); + match self.editing { + Some(LaunchField::Tag) => cursor_right(&self.tag, &mut self.edit_cursor), + Some(LaunchField::Project) => cursor_right(&self.project, &mut self.edit_cursor), + _ => {} } } pub fn field_value(&self, field: LaunchField) -> &str { match field { LaunchField::Tag => &self.tag, + LaunchField::Project => &self.project, _ => "", } } @@ -712,31 +728,44 @@ impl LaunchState { pub fn field_value_mut(&mut self, field: LaunchField) -> Option<&mut String> { match field { LaunchField::Tag => Some(&mut self.tag), + LaunchField::Project => Some(&mut self.project), _ => None, } } pub fn insert_char(&mut self, c: char) { - if let Some(LaunchField::Tag) = self.editing { - insert_at(&mut self.tag, &mut self.edit_cursor, c); + match self.editing { + Some(LaunchField::Tag) => insert_at(&mut self.tag, &mut self.edit_cursor, c), + Some(LaunchField::Project) => insert_at(&mut self.project, &mut self.edit_cursor, c), + _ => {} } } pub fn delete_char(&mut self) { - if let Some(LaunchField::Tag) = self.editing { - delete_back(&mut self.tag, &mut self.edit_cursor); + match self.editing { + Some(LaunchField::Tag) => delete_back(&mut self.tag, &mut self.edit_cursor), + Some(LaunchField::Project) => delete_back(&mut self.project, &mut self.edit_cursor), + _ => {} } } pub fn delete_word(&mut self) { - if let Some(LaunchField::Tag) = self.editing { - delete_word_back(&mut self.tag, &mut self.edit_cursor); + match self.editing { + Some(LaunchField::Tag) => delete_word_back(&mut self.tag, &mut self.edit_cursor), + Some(LaunchField::Project) => delete_word_back(&mut self.project, &mut self.edit_cursor), + _ => {} } } pub fn delete_to_start(&mut self) { - if let Some(LaunchField::Tag) = self.editing { - crate::tui::model::delete_to_start(&mut self.tag, &mut self.edit_cursor); + match self.editing { + Some(LaunchField::Tag) => { + crate::tui::model::delete_to_start(&mut self.tag, &mut self.edit_cursor); + } + Some(LaunchField::Project) => { + crate::tui::model::delete_to_start(&mut self.project, &mut self.edit_cursor); + } + _ => {} } } } @@ -823,6 +852,7 @@ mod tests { has_tcp: true, directory: String::new(), tag: String::new(), + project: String::new(), unread: 0, last_event_id: None, device_name: None, @@ -1250,6 +1280,7 @@ mod tests { count: 1, options_cursor: None, tag: String::new(), + project: String::new(), headless: false, terminal: 0, terminal_presets: vec!["default".into(), "kitty".into()], diff --git a/src/tui/render/agents.rs b/src/tui/render/agents.rs index 0d795c6..b5d0209 100644 --- a/src/tui/render/agents.rs +++ b/src/tui/render/agents.rs @@ -89,6 +89,9 @@ fn tool_prefix_str(tool: Tool) -> &'static str { fn collect_agent_lines(app: &App, width: u16, max_visible: usize) -> Vec> { let mut lines: Vec = Vec::new(); + // Apply project filter + let filter_project = app.ui.project_filter.as_deref().filter(|p| !p.is_empty()); + // Show tool prefix only when agents use different tools let all_agents: Vec<&Agent> = app .data @@ -100,6 +103,14 @@ fn collect_agent_lines(app: &App, width: u16, max_visible: usize) -> Vec = all_agents.iter().map(|a| a.tool.name()).collect(); let multi_tool = tools.len() > 1; + // Filter agents by project + let filtered: Vec<&Agent> = app + .data + .agents + .iter() + .filter(|a| filter_project.map_or(true, |proj| a.project == proj)) + .collect(); + // Compute max display name width for alignment (min 4 to avoid cramping) let name_width = all_agents .iter() @@ -143,13 +154,24 @@ fn collect_agent_lines(app: &App, width: u16, max_visible: usize) -> Vec 0 { @@ -160,7 +182,7 @@ fn collect_agent_lines(app: &App, width: u16, max_visible: usize) -> Vec Vec 0 && lines.len() < max_visible { lines.push(Line::from(Span::styled( - format!(" ↓ {} more", remaining), + format!(" \u{2193} {} more", remaining), Theme::dim(), ))); } @@ -729,8 +751,16 @@ struct TabEntry { fn build_tab_strip(app: &App, width: usize) -> Line<'static> { let mut tabs: Vec = Vec::new(); + let filter_project = app.ui.project_filter.as_deref().filter(|p| !p.is_empty()); + // Live agents - for (i, agent) in app.data.agents.iter().enumerate() { + let filtered: Vec<&Agent> = app + .data + .agents + .iter() + .filter(|a| filter_project.map_or(true, |proj| a.project == proj)) + .collect(); + for (i, agent) in filtered.iter().enumerate() { let is_cursor = app.ui.cursor == i; let is_selected = app.ui.selected.contains(&agent.name); let icon = agent_icon(agent, app.ui.tick); @@ -746,7 +776,7 @@ fn build_tab_strip(app: &App, width: usize) -> Line<'static> { } // Section headers + items (remote, orphans) - let mut offset = app.data.agents.len(); + let mut offset = filtered.len(); if !app.data.remote_agents.is_empty() { let is_cursor = app.ui.cursor == offset; @@ -1032,11 +1062,21 @@ fn build_agent_detail(agent: &Agent, app: &App, lines: &mut Vec>, append_right_aligned(&mut left, right, w); lines.push(Line::from(left)); - // Line 2: directory · [headless]/[terminal] · pid · session · unread + // Line 2: project · tag · directory · [headless]/[terminal] · pid · session · unread let mut info: Vec> = vec![ Span::raw(" "), - Span::styled(shorten_dir(&agent.directory), Theme::dim()), ]; + if !agent.project.is_empty() { + info.push(Span::styled("@", Style::default().fg(palette::CYAN))); + info.push(Span::styled(agent.project.clone(), Style::default().fg(palette::CYAN).add_modifier(Modifier::BOLD))); + info.push(Span::styled(" ", Theme::dim())); + } + if !agent.tag.is_empty() { + info.push(Span::styled("#", Style::default().fg(palette::TEAL))); + info.push(Span::styled(agent.tag.clone(), Style::default().fg(palette::TEAL))); + info.push(Span::styled(" ", Theme::dim())); + } + info.push(Span::styled(shorten_dir(&agent.directory), Theme::dim())); if agent.headless { info.push(Span::styled(" \u{00b7} ", Theme::separator())); info.push(Span::styled("[headless]", Theme::dim())); diff --git a/src/tui/render/launch.rs b/src/tui/render/launch.rs index 317703a..8c51906 100644 --- a/src/tui/render/launch.rs +++ b/src/tui/render/launch.rs @@ -13,9 +13,9 @@ pub fn render_launch_inline(frame: &mut Frame, area: Rect, app: &App) { let w = area.width; let mut lines: Vec = vec![ separator_line(w, Some("launch")), - field_row("Tool", ls.tool.name(), LaunchField::Tool, ls, w), - field_row("Count", &ls.count.to_string(), LaunchField::Count, ls, w), + field_row_dual("Tool", ls.tool.name(), LaunchField::Tool, "Cnt", &ls.count.to_string(), LaunchField::Count, ls, w), field_row_text("Tag", &ls.tag, LaunchField::Tag, ls, w), + field_row_text("Project", &ls.project, LaunchField::Project, ls, w), ]; if ls.tool == Tool::Claude { @@ -51,7 +51,7 @@ fn separator_line(width: u16, label: Option<&str>) -> Line<'static> { Line::from(vec![ Span::raw(" ".repeat(margin)), - Span::styled(prefix.to_string(), Theme::separator()), + Span::styled(prefix, Theme::separator()), Span::styled(label_str, Theme::launch_active()), Span::styled(fill, Theme::separator()), ]) @@ -64,6 +64,56 @@ fn separator_line(width: u16, label: Option<&str>) -> Line<'static> { } } +/// Dual selector field with two values side by side on one line. +fn field_row_dual( + label1: &str, value1: &str, field1: LaunchField, + label2: &str, value2: &str, field2: LaunchField, + ls: &LaunchState, width: u16, +) -> Line<'static> { + let sel = ls.options_cursor == Some(field1) || ls.options_cursor == Some(field2); + let (cursor, cursor_style) = cursor_span(sel); + + let left = field_inline(label1, value1, field1, ls); + let right = field_inline(label2, value2, field2, ls); + let left_w: usize = left.iter().map(|s| s.width()).sum(); + let gap = (width as usize).saturating_sub(left_w + 4 + 4); // 4 = margin+cursor, 4 = spacing + + let mut spans: Vec = vec![ + Span::raw(" "), + Span::styled(cursor, cursor_style), + ]; + spans.extend(left); + spans.push(Span::raw(" ".repeat(gap.saturating_sub(2)))); + spans.push(Span::raw(" ")); + spans.extend(right); + + Line::from(super::fit_spans(spans, width as usize)) +} + +/// Compact inline field part (no margin, no cursor). +fn field_inline(label: &str, value: &str, field: LaunchField, ls: &LaunchState) -> Vec> { + let sel = ls.options_cursor == Some(field); + let arrow = if sel { + Theme::launch_arrow() + } else { + Style::default().fg(palette::FG_DARK) + }; + let val_style = if sel { + Theme::launch_active() + } else { + Style::default().fg(palette::FG) + }; + vec![ + Span::styled( + format!("{: &'static str { OverlayKind::Search => "SEARCH / ", OverlayKind::Command => "CMD ! ", OverlayKind::Tag => "TAG # ", + OverlayKind::Project => "PRJ ! ", + OverlayKind::ProjectFilter => "FILTER @ ", }; } match app.ui.mode { @@ -103,6 +105,8 @@ fn mode_input_bg(app: &App) -> ratatui::style::Color { OverlayKind::Search => palette::MODE_SEARCH, OverlayKind::Command => palette::MODE_CMD, OverlayKind::Tag => palette::MODE_TAG, + OverlayKind::Project => palette::MODE_TAG, + OverlayKind::ProjectFilter => palette::MODE_SEARCH, }; } match app.ui.mode { @@ -1094,11 +1098,15 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) { OverlayKind::Search => ("SEARCH ", Style::default().fg(palette::YELLOW)), OverlayKind::Command => ("CMD ", Style::default().fg(palette::MAGENTA)), OverlayKind::Tag => ("TAG ", Style::default().fg(palette::TEAL)), + OverlayKind::Project => ("PRJ ", Style::default().fg(palette::TEAL)), + OverlayKind::ProjectFilter => ("FILTER ", Style::default().fg(palette::CYAN)), }; let prefix_char = match overlay.kind { OverlayKind::Search => "/ ", OverlayKind::Command => "! ", OverlayKind::Tag => "# ", + OverlayKind::Project => "! ", + OverlayKind::ProjectFilter => "@ ", }; Line::from(vec![ Span::raw(" "), @@ -1305,6 +1313,8 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) { OverlayKind::Search => " keep ", OverlayKind::Command => " run ", OverlayKind::Tag => " set ", + OverlayKind::Project => " set ", + OverlayKind::ProjectFilter => " filter ", }; vec![ Span::raw(" "), diff --git a/src/tui/rpc.rs b/src/tui/rpc.rs index 6656585..66754d3 100644 --- a/src/tui/rpc.rs +++ b/src/tui/rpc.rs @@ -61,6 +61,7 @@ pub fn build_launch_argv( tool: Tool, count: u8, tag: &str, + project: &str, headless: bool, terminal: &str, prompt: &str, @@ -73,6 +74,9 @@ pub fn build_launch_argv( if !tag.is_empty() { argv.extend(["--tag".into(), tag.into()]); } + if !project.is_empty() { + argv.extend(["--project".into(), project.into()]); + } if !terminal.is_empty() { argv.extend(["--terminal".into(), terminal.into()]); } @@ -155,12 +159,14 @@ mod tests { #[test] fn launch_argv_numeric_count_first() { - let argv = build_launch_argv(Tool::Claude, 2, "review", true, "kitty", "hello"); + let argv = build_launch_argv(Tool::Claude, 2, "review", "myproject", true, "kitty", "hello"); assert_eq!(argv[0], "2"); assert_eq!(argv[1], "claude"); assert!(argv.contains(&"--no-run-here".into())); assert!(argv.contains(&"--tag".into())); assert!(argv.contains(&"review".into())); + assert!(argv.contains(&"--project".into())); + assert!(argv.contains(&"myproject".into())); assert!(argv.contains(&"--terminal".into())); assert!(argv.contains(&"kitty".into())); assert!(argv.contains(&"-p".into())); @@ -169,7 +175,7 @@ mod tests { #[test] fn launch_argv_always_includes_no_run_here() { - let argv = build_launch_argv(Tool::Gemini, 1, "", false, "default", ""); + let argv = build_launch_argv(Tool::Gemini, 1, "", "", false, "default", ""); assert_eq!( argv, vec!["1", "gemini", "--no-run-here", "--terminal", "default"] @@ -178,7 +184,7 @@ mod tests { #[test] fn launch_argv_gemini_prompt_uses_dash_i() { - let argv = build_launch_argv(Tool::Gemini, 1, "", false, "kitty", "fix the bug"); + let argv = build_launch_argv(Tool::Gemini, 1, "", "", false, "kitty", "fix the bug"); assert_eq!( argv, vec![ @@ -195,7 +201,7 @@ mod tests { #[test] fn launch_argv_codex_prompt_is_positional() { - let argv = build_launch_argv(Tool::Codex, 1, "", false, "tmux", "do task"); + let argv = build_launch_argv(Tool::Codex, 1, "", "", false, "tmux", "do task"); assert_eq!( argv, vec![ diff --git a/src/tui/rpc_async.rs b/src/tui/rpc_async.rs index 1fd1e41..90d44f6 100644 --- a/src/tui/rpc_async.rs +++ b/src/tui/rpc_async.rs @@ -25,10 +25,15 @@ pub enum RpcOp { name: String, tag: String, }, + Project { + name: String, + project: String, + }, Launch { tool: Tool, count: u8, tag: String, + project: String, headless: bool, terminal: String, prompt: String, @@ -129,6 +134,14 @@ fn run_op(op: &RpcOp) -> Result { tag.clone(), ]), + RpcOp::Project { name, project } => commands::run_native(&[ + "config".into(), + "-i".into(), + name.clone(), + "project".into(), + project.clone(), + ]), + RpcOp::RelayToggle { enable } => { let flag = if *enable { "on" } else { "off" }; commands::run_native(&["relay".into(), flag.into()]) @@ -157,11 +170,12 @@ fn run_op(op: &RpcOp) -> Result { tool, count, tag, + project, headless, terminal, prompt, } => { - let argv = build_launch_argv(*tool, *count, tag, *headless, terminal, prompt); + let argv = build_launch_argv(*tool, *count, tag, project, *headless, terminal, prompt); commands::run_native(&argv) } } diff --git a/src/tui/state.rs b/src/tui/state.rs index e23c6ec..4a763f4 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -95,6 +95,8 @@ pub struct UiState { pub pending_eject_cmd: bool, /// Terminal width, updated each render frame. Used by input handlers for wrap calculations. pub term_width: u16, + /// Filter agents by project name. None = show all. + pub project_filter: Option, } impl UiState { diff --git a/src/tui/status.rs b/src/tui/status.rs index 8091466..33921f2 100644 --- a/src/tui/status.rs +++ b/src/tui/status.rs @@ -163,6 +163,7 @@ mod tests { has_tcp: true, directory: String::new(), tag: String::new(), + project: String::new(), unread: 0, device_name: None, sync_age: None,