From 0185bcc06100705fa762b04dcf5ecb776fb212eb Mon Sep 17 00:00:00 2001 From: bloodcarter Date: Sun, 8 Mar 2026 10:58:57 +0000 Subject: [PATCH 1/5] feat: resume/fork non-hcom sessions by session ID Allow `hcom r ` to find and resume sessions that were not originally launched through hcom. Searches Claude, Codex, and Gemini transcript files on disk, extracts the last working directory, and launches with full hcom wrapping (PTY, hooks, message delivery). Fork (`hcom f `) gets this for free. Active-instance guard prevents resuming a session that is already running under another hcom instance. Co-Authored-By: Claude Opus 4.6 --- src/commands/resume.rs | 313 +++++++++++++++++++++++++++++++++++++ src/commands/transcript.rs | 4 +- 2 files changed, 315 insertions(+), 2 deletions(-) diff --git a/src/commands/resume.rs b/src/commands/resume.rs index c125793..194f5a7 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,11 @@ 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); + } + // For resume (not fork): reject if instance is still active if !fork { if let Ok(Some(_)) = db.get_instance_full(&name) { @@ -391,6 +400,246 @@ 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() +} + +/// 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 +730,70 @@ 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_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") { From 194e4ccb1e25e4a48c78608af08b87e631b63381 Mon Sep 17 00:00:00 2001 From: bloodcarter Date: Wed, 11 Mar 2026 06:13:09 +0000 Subject: [PATCH 2/5] feat: resolve Codex thread names for resume/fork When `hcom r ` is not a UUID and not a known hcom instance, look up ~/.codex/session_index.jsonl for a matching thread_name and resolve it to a session UUID. Picks the most recently updated match when duplicates exist. Co-Authored-By: Claude Opus 4.6 --- src/commands/resume.rs | 108 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/commands/resume.rs b/src/commands/resume.rs index 194f5a7..9cffa65 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -71,6 +71,14 @@ pub fn do_resume( 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) { @@ -405,6 +413,43 @@ 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)> { @@ -794,6 +839,69 @@ mod tests { 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"])); From de2b09068cfa9030743aa64a81ac2464ca96c394 Mon Sep 17 00:00:00 2001 From: bloodcarter Date: Thu, 12 Mar 2026 05:18:31 +0000 Subject: [PATCH 3/5] fix: resolve_display_name handles multi-hyphen tags split_once('-') only tried the first hyphen, failing for tags like "vc-p0-p1-parallel-vani". Now iterates all split points so compound tags resolve correctly. Co-Authored-By: Claude Opus 4.6 --- src/instances.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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( From 89b13cd6b7efae46220172b94ae938063bfd3cf1 Mon Sep 17 00:00:00 2001 From: bloodcarter Date: Thu, 12 Mar 2026 05:30:47 +0000 Subject: [PATCH 4/5] docs(skill): add "No inject port" troubleshooting to agent skill Covers multi-hyphen tag resolution, PTY wrapping requirement, and base-name fallback for agents hitting this failure mode. Co-Authored-By: Claude Opus 4.6 --- skills/hcom-agent-messaging/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/skills/hcom-agent-messaging/SKILL.md b/skills/hcom-agent-messaging/SKILL.md index 2ac1e01..02adcb8 100644 --- a/skills/hcom-agent-messaging/SKILL.md +++ b/skills/hcom-agent-messaging/SKILL.md @@ -101,6 +101,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`? From 7939955dd22f938c9f93df2bd58168ba8cdf6d61 Mon Sep 17 00:00:00 2001 From: bloodcarter Date: Thu, 12 Mar 2026 05:47:27 +0000 Subject: [PATCH 5/5] docs(skill): clarify send vs term inject usage Agents were using term inject for task delegation instead of hcom send, then listening for replies that never arrive. Added explicit guidance on when to use each, with correct/incorrect examples. Co-Authored-By: Claude Opus 4.6 --- skills/hcom-agent-messaging/SKILL.md | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/skills/hcom-agent-messaging/SKILL.md b/skills/hcom-agent-messaging/SKILL.md index 02adcb8..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: