diff --git a/skills/hcom-agent-messaging/SKILL.md b/skills/hcom-agent-messaging/SKILL.md index 2ac1e01..3a86c33 100644 --- a/skills/hcom-agent-messaging/SKILL.md +++ b/skills/hcom-agent-messaging/SKILL.md @@ -36,7 +36,7 @@ Tell any agent: --- -## What agents can do +## What agents can do - Message each other (@mentions, intents, threads, broadcast) - Read each other's transcripts (ranges, detail levels) @@ -50,6 +50,37 @@ Tell any agent: --- +## `hcom send` vs `hcom term inject` — when to use which + +**`hcom send`** — for task instructions, questions, coordination between agents. +The recipient's AI tool receives the message through its hooks and processes it as a first-class input. The sender can then `hcom listen` for a reply. + +```bash +# Correct: task delegation via messaging +hcom send silo "Run focus-routing E2E: cd ~/repos/dasha-code && npm run test:e2e -- --only focus-routing" +hcom listen 60 # wait for reply +``` + +**`hcom term inject`** — for terminal-level control only: pressing Enter on a confirmation prompt, approving a tool call, typing 'y' to proceed. The target AI tool does NOT know the text came from another agent. Do NOT use for task instructions. + +```bash +# Correct: approve a pending prompt +hcom term inject silo --enter # press Enter +hcom term inject silo "y" --enter # type 'y' and press Enter + +# WRONG: sending task instructions via inject +# The AI tool may not be at a prompt, text may be lost or garbled +hcom term inject silo "Run the E2E tests now" # DON'T DO THIS +``` + +**Rules:** +- **Sending tasks/instructions to another agent** → always `hcom send` +- **Approving a prompt, pressing enter, typing a short confirmation** → `hcom term inject` +- **Never mix**: don't `term inject` a task then `hcom listen` for a reply — `listen` only receives `hcom send` messages, not terminal output +- **Always quote inject text**: `hcom term inject "quoted text" --enter` + +--- + ## Setup If the user invokes this skill without arguments: @@ -101,6 +132,15 @@ hcom reset all && hcom hooks add hcom claude # Fresh start ``` +### "No inject port for ..." + +This means `hcom term` could not resolve the display name to a running PTY instance. + +1. **Check hcom version:** Multi-hyphen tags (e.g., `vc-p0-p1-parallel-vani`) require hcom ≥ 0.7.5. Run `pip install --upgrade hcom` if needed. +2. **Check the instance is running:** `hcom list` — is the base instance name present and `active`? +3. **Check PTY wrapping:** The instance must have been launched via `hcom N ` (PTY-wrapped). Instances started with `hcom start` from inside a session do not get PTY injection ports. +4. **Try the base name directly:** `hcom term ` (e.g., `hcom term vani` instead of `hcom term vc-p0-p1-parallel-vani`). + ### "messages not arriving" 1. **Check recipient:** `hcom list` — are they `listening` or `active`? diff --git a/src/commands/resume.rs b/src/commands/resume.rs index c125793..9cffa65 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -5,9 +5,13 @@ use anyhow::{Result, bail}; use serde_json::json; +use std::io::BufRead; +use crate::commands::transcript::{claude_config_dir, detect_agent_type}; use crate::db::HcomDb; use crate::hooks::claude_args; +use crate::hooks::codex::derive_codex_transcript_path; +use crate::hooks::gemini::derive_gemini_transcript_path; use crate::identity; use crate::launcher::{self, LaunchParams}; use crate::log::log_info; @@ -62,6 +66,19 @@ pub fn do_resume( let name = crate::instances::resolve_display_name_or_stopped(&db, name) .unwrap_or_else(|| name.to_string()); + // If the input looks like a session UUID, branch to session-ID resume + if is_session_id(&name) { + return do_resume_by_session_id(&name, fork, extra_args, flags, &db); + } + + // If not a UUID and not a known hcom instance, try resolving as a Codex thread name + if matches!(db.get_instance_full(&name), Ok(None) | Err(_)) { + if let Some(session_id) = resolve_codex_thread_name(&name) { + eprintln!("Resolved Codex thread '{}' → {}", name, session_id); + return do_resume_by_session_id(&session_id, fork, extra_args, flags, &db); + } + } + // For resume (not fork): reject if instance is still active if !fork { if let Ok(Some(_)) = db.get_instance_full(&name) { @@ -391,6 +408,283 @@ fn is_headless_from_args(tool: &str, args: &[String]) -> bool { } } +/// Check if a string looks like a UUID session ID. +fn is_session_id(s: &str) -> bool { + uuid::Uuid::parse_str(s).is_ok() +} + +/// Resolve a Codex thread name (e.g. "stabilization-review") to a session UUID +/// by looking up ~/.codex/session_index.jsonl. +fn resolve_codex_thread_name(name: &str) -> Option { + let index_path = dirs::home_dir()?.join(".codex/session_index.jsonl"); + let file = std::fs::File::open(&index_path).ok()?; + let reader = std::io::BufReader::new(file); + let mut best_match: Option<(String, String)> = None; // (id, updated_at) + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if line.is_empty() { + continue; + } + let parsed: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(thread_name) = parsed.get("thread_name").and_then(|v| v.as_str()) { + if thread_name == name { + let id = parsed.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let updated = parsed.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if !id.is_empty() { + // Keep the most recently updated match + if best_match.as_ref().map_or(true, |(_, prev_updated)| updated > *prev_updated) { + best_match = Some((id, updated)); + } + } + } + } + } + + best_match.map(|(id, _)| id) +} + +/// Find a session transcript on disk by session ID. +/// Returns (tool, transcript_path) if found. +fn find_session_on_disk(session_id: &str) -> Option<(String, String)> { + // 1. Claude: iterate project dirs, check for exact filename + let projects_dir = claude_config_dir().join("projects"); + if projects_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&projects_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + let candidate = entry.path().join(format!("{}.jsonl", session_id)); + if candidate.exists() { + let path_str = candidate.to_string_lossy().to_string(); + let tool = detect_agent_type(&path_str).to_string(); + return Some((tool, path_str)); + } + } + } + } + } + + // 2. Codex: reuse existing glob-based search + if let Some(path) = derive_codex_transcript_path(session_id) { + let tool = detect_agent_type(&path).to_string(); + return Some((tool, path)); + } + + // 3. Gemini: reuse existing prefix-based search + if let Some(path) = derive_gemini_transcript_path(session_id) { + let tool = detect_agent_type(&path).to_string(); + return Some((tool, path)); + } + + None +} + +/// Extract the last working directory from a session transcript. +/// Returns None if no CWD found (e.g., Gemini transcripts). +fn extract_last_cwd(path: &str, tool: &str) -> Option { + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + let mut last_cwd: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if line.is_empty() { + continue; + } + + let parsed: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + match tool { + "claude" => { + // Top-level "cwd" key on most JSONL lines + if let Some(cwd) = parsed.get("cwd").and_then(|v| v.as_str()) { + if !cwd.is_empty() { + last_cwd = Some(cwd.to_string()); + } + } + } + "codex" => { + // payload.cwd on turn_context or session_meta lines + if let Some(cwd) = parsed + .get("payload") + .and_then(|p| p.get("cwd")) + .and_then(|v| v.as_str()) + { + if !cwd.is_empty() { + last_cwd = Some(cwd.to_string()); + } + } + } + _ => { + // Gemini and others: no CWD in transcripts + return None; + } + } + } + + last_cwd +} + +/// Resume/fork a session identified by UUID, not by hcom instance name. +fn do_resume_by_session_id( + session_id: &str, + fork: bool, + extra_args: &[String], + flags: &GlobalFlags, + db: &HcomDb, +) -> Result { + // Check if any active instance holds this session + if let Ok(Some(instance_name)) = db.get_session_binding(session_id) { + if let Ok(Some(_)) = db.get_instance_full(&instance_name) { + bail!( + "Session {} is currently active as '{}' — kill it first or resume by name", + session_id, + instance_name + ); + } + // Instance exists in session_bindings but is not active — delegate to existing name-based path + return do_resume(&instance_name, fork, extra_args, flags); + } + + // Not in DB — search for transcript on disk + let (tool, transcript_path) = find_session_on_disk(session_id).ok_or_else(|| { + let projects_dir = claude_config_dir().join("projects"); + anyhow::anyhow!( + "Session {} not found. Searched:\n - Claude: {}/*/{}.jsonl\n - Codex: ~/.codex/sessions/**/*-{}.jsonl\n - Gemini: ~/.gemini/tmp/*/chats/session-*-{}*.json", + session_id, + projects_dir.display(), + session_id, + session_id, + &session_id.split('-').next().unwrap_or(session_id), + ) + })?; + + // Extract hcom-level flags + let (tag_override, terminal_override, dir_override, clean_extra) = extract_hcom_flags(extra_args); + + // Determine working directory + let effective_cwd = if let Some(ref dir) = dir_override { + let path = std::path::Path::new(dir); + if !path.is_dir() { + bail!("--dir path does not exist or is not a directory: {}", dir); + } + path.canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| dir.clone()) + } else if fork { + // Fork uses current directory + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()) + } else { + // Resume uses transcript's last CWD + match extract_last_cwd(&transcript_path, &tool) { + Some(cwd) if std::path::Path::new(&cwd).is_dir() => cwd, + Some(cwd) => { + eprintln!( + "Warning: transcript directory '{}' no longer exists, using current directory", + cwd + ); + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()) + } + None => { + if tool == "gemini" { + eprintln!( + "Warning: Gemini transcripts don't store working directory — using current directory. Use --dir to override." + ); + } + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()) + } + } + }; + + // Build tool-specific resume args + let mut tool_args = build_resume_args(&tool, session_id, fork); + tool_args.extend(clean_extra.into_iter()); + + // Detect headless + let is_headless = is_headless_from_args(&tool, &tool_args); + let use_pty = tool == "claude" && !is_headless && cfg!(unix); + + // Resolve launcher name + let launcher_name = flags.name.clone().unwrap_or_else(|| { + identity::resolve_identity( + db, None, None, None, + std::env::var("HCOM_PROCESS_ID").ok().as_deref(), + None, None, + ) + .map(|id| id.name) + .unwrap_or_else(|_| "user".to_string()) + }); + + // Launch + let result = launcher::launch( + db, + LaunchParams { + tool: tool.clone(), + count: 1, + args: tool_args, + tag: tag_override, + system_prompt: Some(if fork { + format!( + "YOUR SESSION HAS BEEN FORKED from session {}. \ + You have the same history but are a NEW agent under hcom management. \ + Run hcom start to get your own identity.", + session_id + ) + } else { + "YOUR SESSION HAS BEEN RESUMED under hcom management.".to_string() + }), + pty: use_pty, + background: is_headless, + cwd: Some(effective_cwd.clone()), + env: None, + launcher: Some(launcher_name), + run_here: None, + initial_prompt: None, + batch_id: None, + name: None, // auto-generated + skip_validation: true, + terminal: terminal_override, + }, + )?; + + if result.launched > 0 { + let action = if fork { "Forked" } else { "Resumed" }; + println!( + "{} session {} ({}) in {}", + action, session_id, tool, effective_cwd + ); + } + + log_info( + if fork { "fork" } else { "resume" }, + &format!("cmd.{}_session", if fork { "fork" } else { "resume" }), + &format!( + "session_id={} tool={} transcript={} launched={}", + session_id, tool, transcript_path, result.launched + ), + ); + + Ok(if result.launched > 0 { 0 } else { 1 }) +} + #[cfg(test)] mod tests { use super::*; @@ -481,6 +775,133 @@ mod tests { assert!(remaining.is_empty()); } + #[test] + fn test_is_session_id_valid() { + assert!(is_session_id("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + assert!(is_session_id("521cfc2b-be38-403a-b32e-4a49c9551b27")); + } + + #[test] + fn test_is_session_id_rejects_names() { + assert!(!is_session_id("cafe")); + assert!(!is_session_id("boho")); + assert!(!is_session_id("my-agent")); + assert!(!is_session_id("impl-luna")); + assert!(!is_session_id("review-kira")); + assert!(!is_session_id("")); + } + + #[test] + fn test_extract_last_cwd_claude() { + let dir = std::env::temp_dir().join("hcom_test_cwd_claude"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.jsonl"); + std::fs::write( + &path, + r#"{"type":"user","cwd":"/first/dir","message":"hi"} +{"type":"assistant","cwd":"/first/dir","message":"hello"} +{"type":"user","cwd":"/second/dir","message":"cd somewhere"} +{"type":"assistant","cwd":"/second/dir","message":"ok"} +"#, + ) + .unwrap(); + let result = extract_last_cwd(path.to_str().unwrap(), "claude"); + assert_eq!(result, Some("/second/dir".to_string())); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_extract_last_cwd_codex() { + let dir = std::env::temp_dir().join("hcom_test_cwd_codex"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.jsonl"); + std::fs::write( + &path, + r#"{"type":"session_meta","payload":{"cwd":"/start/dir"}} +{"type":"event_msg","payload":{"content":"hello"}} +{"type":"turn_context","payload":{"cwd":"/changed/dir"}} +"#, + ) + .unwrap(); + let result = extract_last_cwd(path.to_str().unwrap(), "codex"); + assert_eq!(result, Some("/changed/dir".to_string())); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_extract_last_cwd_gemini() { + let dir = std::env::temp_dir().join("hcom_test_cwd_gemini"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.json"); + std::fs::write(&path, r#"{"messages":[]}"#).unwrap(); + let result = extract_last_cwd(path.to_str().unwrap(), "gemini"); + assert_eq!(result, None); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_resolve_codex_thread_name_found() { + let dir = std::env::temp_dir().join("hcom_test_codex_thread"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session_index.jsonl"); + std::fs::write( + &path, + r#"{"id":"019c9622-196a-7490-b8d9-4a0866b5990e","thread_name":"(no title)","updated_at":"2026-02-26T18:16:27Z"} +{"id":"019c9ecc-8c3e-7320-9510-3cfafe24b9f2","thread_name":"stabilization-review","updated_at":"2026-03-04T10:57:33Z"} +"#, + ) + .unwrap(); + + // Test resolution by directly parsing — we can't override home_dir(), + // so test the parsing logic inline + let file = std::fs::File::open(&path).unwrap(); + let reader = std::io::BufReader::new(file); + let mut found_id = None; + for line in reader.lines() { + let line = line.unwrap(); + if line.is_empty() { continue; } + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + if parsed.get("thread_name").and_then(|v| v.as_str()) == Some("stabilization-review") { + found_id = parsed.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + } + assert_eq!(found_id, Some("019c9ecc-8c3e-7320-9510-3cfafe24b9f2".to_string())); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_resolve_codex_thread_name_picks_latest() { + // When multiple entries have the same thread_name, pick the latest updated_at + let dir = std::env::temp_dir().join("hcom_test_codex_thread_latest"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session_index.jsonl"); + std::fs::write( + &path, + r#"{"id":"aaa-old","thread_name":"my-thread","updated_at":"2026-01-01T00:00:00Z"} +{"id":"bbb-new","thread_name":"my-thread","updated_at":"2026-03-01T00:00:00Z"} +"#, + ) + .unwrap(); + + let file = std::fs::File::open(&path).unwrap(); + let reader = std::io::BufReader::new(file); + let mut best: Option<(String, String)> = None; + for line in reader.lines() { + let line = line.unwrap(); + if line.is_empty() { continue; } + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + if parsed.get("thread_name").and_then(|v| v.as_str()) == Some("my-thread") { + let id = parsed.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let updated = parsed.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if best.as_ref().map_or(true, |(_, prev)| updated > *prev) { + best = Some((id, updated)); + } + } + } + assert_eq!(best.unwrap().0, "bbb-new"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_extract_hcom_flags_none() { let (tag, terminal, dir, remaining) = extract_hcom_flags(&s(&["--model", "opus"])); diff --git a/src/commands/transcript.rs b/src/commands/transcript.rs index fb8c06d..8a408a3 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -264,14 +264,14 @@ fn normalize_tool_name(name: &str) -> &str { // ── Transcript Path Discovery ──────────────────────────────────────────── /// Get Claude config directory. -fn claude_config_dir() -> PathBuf { +pub(crate) fn claude_config_dir() -> PathBuf { std::env::var("CLAUDE_CONFIG_DIR") .map(PathBuf::from) .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".claude")) } /// Detect agent type from transcript path. -fn detect_agent_type(path: &str) -> &str { +pub(crate) fn detect_agent_type(path: &str) -> &str { if path.contains(".claude") || path.contains("/projects/") { "claude" } else if path.contains(".gemini") { diff --git a/src/instances.rs b/src/instances.rs index 9e433da..a7251a5 100644 --- a/src/instances.rs +++ b/src/instances.rs @@ -559,13 +559,19 @@ pub fn get_display_name(db: &HcomDb, base_name: &str) -> String { } /// Resolve base name or tag-name (e.g., "team-luna") to base name. +/// Handles multi-hyphen tags like "vc-p0-p1-parallel-vani" → tag="vc-p0-p1-parallel", name="vani". pub fn resolve_display_name(db: &HcomDb, input_name: &str) -> Option { // Direct match if let Ok(Some(_)) = db.get_instance_full(input_name) { return Some(input_name.to_string()); } - // Try tag-name split - if let Some((tag, name)) = input_name.split_once('-') { + // Try all possible tag-name split points (tags can contain hyphens) + for (i, _) in input_name.match_indices('-') { + let tag = &input_name[..i]; + let name = &input_name[i + 1..]; + if name.is_empty() { + continue; + } if let Ok(Some(data)) = db.get_instance_full(name) { if data.tag.as_deref() == Some(tag) { return Some(name.to_string()); @@ -599,8 +605,13 @@ pub fn resolve_display_name_or_stopped(db: &HcomDb, input_name: &str) -> Option< return Some(input_name.to_string()); } - // Stopped tag-name match against the stored snapshot tag. - if let Some((tag, name)) = input_name.split_once('-') { + // Stopped tag-name match against the stored snapshot tag (try all split points). + for (i, _) in input_name.match_indices('-') { + let tag = &input_name[..i]; + let name = &input_name[i + 1..]; + if name.is_empty() { + continue; + } if db .conn() .query_row(