diff --git a/src/claude_mod/hooks/register.js b/src/claude_mod/hooks/register.js index f637109f..e15557b9 100644 --- a/src/claude_mod/hooks/register.js +++ b/src/claude_mod/hooks/register.js @@ -51,21 +51,23 @@ async function poll($) { if (currentGeneration !== generation || !state.wake || submitting) return; submitting = true; submittingSession = session; - // A queued submission must not block polling or a lifecycle hook. + // A queued submission must not block polling or a lifecycle hook. Polls + // only observe until its outcome is reported, so hcom judges the outcome + // against this wake's claim, never against a newer one. try { void $.prompt.submit({ text: '', asUser: true }).then( async (result) => { - submitting = false; - // A hook may drop the wake; nothing was delivered, so it is a failure. + // A hook may drop the wake; hcom decides whether that was a failure. if (result?.drop) { await report($, session, 'submission-failed', 'wake dropped: ' + result.drop); } else { await report($, session, 'submitted', 'Claude completed the native wake submission'); } + submitting = false; }, async (error) => { - submitting = false; await report($, session, 'submission-failed', String(error)); + submitting = false; $.ui.log('hcom mod wake failed; messages remain queued: ' + String(error)); }, ); diff --git a/src/claude_mod/tests/register.test.ts b/src/claude_mod/tests/register.test.ts index ad39f20f..1b594dde 100644 --- a/src/claude_mod/tests/register.test.ts +++ b/src/claude_mod/tests/register.test.ts @@ -154,6 +154,31 @@ test('a dropped wake is reported as a failure, not a submission', async () => { expect(reports[0].includes('wake dropped: blocked by another plugin')).toBe(true) }) +test('polls keep observing until a finished wake is reported', async () => { + const h = harness() + const calls = [] as string[][] + let finishReport: Function + h.api.process.run = (argv: string[]) => { + calls.push(argv) + if (argv.includes('--event')) return new Promise(resolve => { finishReport = resolve }) as any + return Promise.resolve({ exitCode: 0, stdout: '{"wake":true}' }) as any + } + await h.handlers['session.start'](h.api, interactive, next) + await h.tick() + h.finishPrompt() + for (let i = 0; i < 5; i++) await Promise.resolve() + // The outcome report is still running: a poll now must not claim a wake. + await h.tick() + expect(calls.at(-1)!.includes('--observe')).toBe(true) + expect(h.prompts.length).toBe(1) + finishReport!({ exitCode: 0, stdout: '{"wake":false}' }) + for (let i = 0; i < 5; i++) await Promise.resolve() + await h.tick() + expect(calls.at(-1)!.includes('--observe')).toBe(false) + expect(h.prompts.length).toBe(2) + h.finishPrompt() +}) + test('a failed poll backs off instead of respawning every tick', async () => { const h = harness() let polls = 0 diff --git a/src/delivery.rs b/src/delivery.rs index 90bf5af5..127594cc 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -557,6 +557,9 @@ mod host_label { } } +/// The bare wake typed into a tool whose hooks show the delivery themselves. +pub(crate) const WAKE_TRIGGER: &str = ""; + /// Human-readable descriptions for gate block reasons. pub(crate) fn gate_block_detail(reason: &str) -> &'static str { match reason { @@ -681,6 +684,8 @@ fn format_wake_message_line( /// 6. `require_prompt_empty` - Check if prompt has no user text. /// Claude-specific: Uses VT100 dim attribute detection to distinguish placeholder text /// (dim) from user input (not dim). Implemented in screen.rs get_claude_input_text(). +/// A Claude box holding only a leftover `` wake is submitted with Enter +/// rather than waited on (`ScreenState::leftover_wake`). #[derive(Clone)] pub struct ToolConfig { /// Tool name (claude, gemini, codex) @@ -765,6 +770,17 @@ pub struct GateResult { pub reason: &'static str, } +/// Gate reason for an open gate whose prompt already holds a leftover wake. +const REASON_LEFTOVER_WAKE: &str = "leftover_wake"; + +impl GateResult { + /// The prompt already holds only a leftover `` wake: deliver by + /// pressing Enter on it rather than typing another one beside it. + pub fn submits_leftover(&self) -> bool { + self.safe && self.reason == REASON_LEFTOVER_WAKE + } +} + /// Shared state for delivery thread pub struct DeliveryState { pub screen: Arc>, @@ -873,6 +889,13 @@ pub struct ScreenState { /// set. Only ever true for Claude (see `ScreenTracker::is_claude_subagent_nav_visible` /// / `is_claude_session_switcher_visible`). pub nav_overlay: bool, + /// Claude's input box holds only a leftover `` wake on one row (see + /// `ScreenTracker::is_claude_leftover_wake`): typically a bare wake its hook + /// blocked with nothing pending, which Claude put back into the box. It is + /// hcom's own trigger, not a draft, so the gate submits it with Enter + /// instead of waiting for an empty prompt that never comes. Only ever true + /// for Claude. + pub leftover_wake: bool, /// Codex is still starting with nothing to answer (see /// `ScreenTracker::is_codex_startup_loading`); the launch-blocked heuristic /// waits it out rather than reading a quiet loading screen as settled. @@ -894,6 +917,7 @@ impl Default for ScreenState { approval_scrape_latched: false, approval_scrape_negative_since: None, nav_overlay: false, + leftover_wake: false, startup_loading: false, } } @@ -972,7 +996,9 @@ impl DeliveryState { /// 3. block_on_user_activity - if user recently typed, reason="user_active" /// 4. submit-settle cooldown - if prompt just submitted, reason="submit_settle" /// 5. require_ready_prompt - if prompt not visible, reason="not_ready" -/// 6. require_prompt_empty - if prompt has user text, reason="prompt_has_text" +/// 6. require_prompt_empty - if prompt has user text, reason="prompt_has_text", +/// unless it is only a leftover `` wake (Claude): then the gate opens +/// with reason="leftover_wake" and the loop presses Enter alone /// /// The delivery loop checks screen.approval directly for status="blocked", /// so Codex OSC9 detection works even when agent is active (gate returns "not_idle"). @@ -1035,6 +1061,16 @@ pub(crate) fn evaluate_gate( }; } if config.require_prompt_empty && !screen.prompt_empty { + // Our own ``, alone in Claude's box (put back after its hook + // blocked a bare wake), is not a draft to protect: treating it as one + // held every later message at `prompt_has_text`. + // Submitting it hands the pending batch to the UserPromptSubmit hook. + if screen.leftover_wake { + return GateResult { + safe: true, + reason: REASON_LEFTOVER_WAKE, + }; + } return GateResult { safe: false, reason: "prompt_has_text", @@ -1359,6 +1395,17 @@ fn prompt_ownership(input_text: Option<&str>, injected_text: &str) -> PromptOwne } } +/// Ownership of the prompt before an Enter (first or retry). A leftover wake is +/// only ours while the scrape still sees it alone on one row: the prompt row +/// keeps reading `` after the user adds a second line, so text alone +/// cannot tell the leftover from a draft that starts with it. +fn submit_ownership(screen: &ScreenState, injected_text: &str, leftover: bool) -> PromptOwnership { + match prompt_ownership(screen.input_text.as_deref(), injected_text) { + PromptOwnership::Exclusive if leftover && !screen.leftover_wake => PromptOwnership::Mixed, + ownership => ownership, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Phase1Decision { Rendered, @@ -1846,6 +1893,7 @@ pub fn run_delivery_loop( let mut inject_attempt: u32 = 0; let mut enter_attempt: u32 = 0; let mut injected_text = String::new(); + let mut submitting_leftover = false; let mut phase_started_at = Instant::now(); let mut cursor_before: i64 = 0; // Gate block tracking for TUI status updates @@ -2032,6 +2080,24 @@ pub fn run_delivery_loop( continue; } + // The prompt already holds our ``: type nothing and + // let the render phase see it as this attempt's text. It + // re-checks ownership, approval and typing from a fresh + // snapshot before the Enter, then verifies as usual. + if gate.submits_leftover() { + log_info( + "native", + "delivery.leftover_wake", + "Prompt holds only a leftover wake; submitting it with Enter", + ); + injected_text = WAKE_TRIGGER.to_string(); + submitting_leftover = true; + phase_started_at = Instant::now(); + enter_attempt = 0; + delivery_state = State::WaitTextRender; + continue; + } + // Claude/Codex hooks show the full delivery in the TUI, so // they only need a trigger. Gemini-style paths use a // compact, prompt-safe preview for human visibility. @@ -2044,7 +2110,7 @@ pub fn run_delivery_loop( let text = match parsed_tool { Some(Tool::Claude) | Some(Tool::Codex) | Some(Tool::Cursor) | Some(Tool::Kimi) | Some(Tool::Copilot) | Some(Tool::Qoder) - | Some(Tool::Pi) | Some(Tool::Omp) => "".to_string(), + | Some(Tool::Pi) | Some(Tool::Omp) => WAKE_TRIGGER.to_string(), _ => build_wake_inject_text(db, ¤t_name, input_box_width), }; @@ -2060,6 +2126,7 @@ pub fn run_delivery_loop( ), ); injected_text = text; + submitting_leftover = false; phase_started_at = Instant::now(); enter_attempt = 0; delivery_state = State::WaitTextRender; @@ -2246,7 +2313,7 @@ pub fn run_delivery_loop( let screen = state.screen.read().unwrap(); let approval = screen.approval; let ownership = - prompt_ownership(screen.input_text.as_deref(), &injected_text); + submit_ownership(&screen, &injected_text, submitting_leftover); drop(screen); if ownership != PromptOwnership::Exclusive { @@ -2345,7 +2412,7 @@ pub fn run_delivery_loop( let screen = state.screen.read().unwrap(); let approval = screen.approval; let ownership = - prompt_ownership(screen.input_text.as_deref(), &injected_text); + submit_ownership(&screen, &injected_text, submitting_leftover); drop(screen); if ownership != PromptOwnership::Exclusive { @@ -2802,6 +2869,7 @@ mod tests { approval_scrape_latched: false, approval_scrape_negative_since: None, nav_overlay: false, + leftover_wake: false, startup_loading: false, } } @@ -3255,6 +3323,107 @@ mod tests { assert_eq!(result.reason, "prompt_has_text"); } + /// Claude's prompt as the PTY scrape reports it: `text` typed in the box, + /// with `leftover_wake` as `ScreenTracker::is_claude_leftover_wake` sets it. + fn claude_prompt(text: &str, leftover_wake: bool) -> ScreenState { + ScreenState { + prompt_empty: text.is_empty(), + input_text: Some(text.to_string()), + leftover_wake, + ..safe_screen() + } + } + + #[test] + fn gate_submits_a_leftover_wake_but_never_a_draft() { + let config = ToolConfig::claude(); + + // Only our own wake in the box: open, and press Enter on it. + let state = make_state(claude_prompt("", true), 500); + let result = evaluate_gate(&config, &state, true); + assert!(result.safe, "leftover wake held: {}", result.reason); + assert!(result.submits_leftover()); + assert_eq!(result.reason, "leftover_wake"); + + // A draft that merely starts with the wake stays protected. + let state = make_state(claude_prompt(" hi", false), 500); + let result = evaluate_gate(&config, &state, true); + assert!(!result.safe); + assert!(!result.submits_leftover()); + assert_eq!(result.reason, "prompt_has_text"); + + // An empty box takes the normal typed wake. + let state = make_state(claude_prompt("", false), 500); + let result = evaluate_gate(&config, &state, true); + assert!(result.safe); + assert!(!result.submits_leftover()); + assert_eq!(result.reason, "ok"); + } + + #[test] + fn gate_leftover_wake_still_waits_for_idle_and_the_user() { + // The leftover only stands in for an empty prompt; every earlier gate + // (idle, approval, typing, overlays, submit settle) still applies. + let config = ToolConfig::claude(); + let gate = |screen: ScreenState, idle: bool| { + let result = evaluate_gate(&config, &make_state(screen, 500), idle); + assert!(!result.safe, "{}: gate opened", result.reason); + assert!(!result.submits_leftover(), "{}", result.reason); + result.reason + }; + assert_eq!(gate(claude_prompt("", true), false), "not_idle"); + + type Tweak = fn(&mut ScreenState); + let tweaks: [(&str, Tweak); 4] = [ + ("approval", |s| s.approval = true), + ("user_active", |s| s.last_user_input = Instant::now()), + ("nav_overlay", |s| s.nav_overlay = true), + ("submit_settle", |s| { + s.last_prompt_submit = Some(Instant::now()) + }), + ]; + for (reason, tweak) in tweaks { + let mut screen = claude_prompt("", true); + tweak(&mut screen); + assert_eq!(gate(screen, true), reason); + } + } + + #[test] + fn leftover_wake_is_rendered_text_for_the_submit_phase() { + // The loop hands the leftover to the render phase as this attempt's + // text, so the usual exclusive-ownership check guards the Enter. + assert_eq!( + phase1_decision(Some(""), WAKE_TRIGGER, Duration::ZERO), + Phase1Decision::Rendered + ); + assert_eq!( + phase1_decision(Some(" hi"), WAKE_TRIGGER, Duration::ZERO), + Phase1Decision::MixedPrompt + ); + } + + #[test] + fn leftover_wake_enter_needs_the_leftover_to_still_hold() { + // After the gate opened on a leftover, the user adds a second line: + // the prompt row still reads ``, but the scrape no longer sees a + // lone wake. Neither the first Enter nor a retry may submit it. + let draft = claude_prompt("", false); + assert_eq!( + submit_ownership(&draft, WAKE_TRIGGER, true), + PromptOwnership::Mixed + ); + assert_eq!( + submit_ownership(&claude_prompt("", true), WAKE_TRIGGER, true), + PromptOwnership::Exclusive + ); + // A wake hcom typed itself keeps the usual text check. + assert_eq!( + submit_ownership(&draft, WAKE_TRIGGER, false), + PromptOwnership::Exclusive + ); + } + fn open_ready_test_db() -> (tempfile::TempDir, HcomDb) { let dir = tempfile::tempdir().unwrap(); let db = HcomDb::open_raw(&dir.path().join("test.db")).unwrap(); diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index bfa1e08d..37a7beb9 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1890,12 +1890,15 @@ fn handle_userpromptsubmit( ); } - lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); + // Another hook already delivered the batch this wake was sent for. A + // blocked prompt runs no turn and no Stop follows, so leave the status: + // listening after Stop, the turn's own status for a prompt queued mid-turn. if is_bare_hcom_wake(payload) { return block_bare_hcom_wake( "This hcom wake arrived after another hook handled the queued delivery. Nothing is pending; no action is needed.", ); } + lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); (0, String::new(), None) } @@ -5232,6 +5235,324 @@ mod tests { assert!(ack.is_none()); } + /// Hook context of a PTY-launched Claude whose name is already announced. + fn make_pty_ctx() -> HcomContext { + let env = std::collections::HashMap::from([ + ("HCOM_PROCESS_ID".to_string(), "process-test".to_string()), + ("HCOM_PTY_MODE".to_string(), "1".to_string()), + ]); + HcomContext::from_env(&env, PathBuf::from("/tmp")) + } + + fn insert_broadcast_from_luna(db: &HcomDb, text: &str) { + let data = serde_json::json!({"from": "luna", "text": text, "scope": "broadcast"}); + db.conn() + .execute( + "INSERT INTO events (type, timestamp, instance, data) + VALUES ('message', '2026-01-01T00:00:00Z', 'luna', ?)", + [data.to_string()], + ) + .unwrap(); + } + + /// Claude's screen sitting at an empty, settled prompt. + fn idle_prompt_delivery_state() -> crate::delivery::DeliveryState { + let settled = Instant::now() + .checked_sub(std::time::Duration::from_secs(30)) + .unwrap(); + let screen = crate::delivery::ScreenState { + ready: true, + prompt_empty: true, + input_text: Some(String::new()), + last_user_input: settled, + last_output: settled, + ..Default::default() + }; + crate::delivery::DeliveryState { + screen: std::sync::Arc::new(std::sync::RwLock::new(screen)), + grok_acp: None, + launch_phase_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + inject_port: 0, + user_activity_cooldown_ms: 3000, + } + } + + fn userpromptsubmit_wake(db: &HcomDb, ctx: &HcomContext) -> (i32, String, Option) { + userpromptsubmit_prompt(db, ctx, "") + } + + fn userpromptsubmit_prompt( + db: &HcomDb, + ctx: &HcomContext, + prompt: &str, + ) -> (i32, String, Option) { + let payload = HookPayload::from_claude(serde_json::json!({ + "session_id": "sess-1", + "prompt": prompt, + })); + let instance = db.get_instance_full("nova").unwrap().unwrap(); + handle_userpromptsubmit( + db, + ctx, + &payload, + "nova", + &serde_json::Map::new(), + &instance, + ) + } + + /// Regression: the PTY typed `` just as a + /// background task's notification started a turn, so Claude queued it. + /// That turn's hook delivered the batch, and the queued wake came in right + /// after Stop with nothing pending. Blocking it left the row `active`, so + /// no turn ran, no Stop followed, and the PTY gate held every later message + /// at `not_idle` (4.5 minutes, 10 unread, until a manual wake). + #[test] + #[serial] + fn test_blocked_bare_wake_stays_idle_so_the_next_message_wakes_it() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id, name_announced) + VALUES ('nova', 'sess-1', 'claude', 'active', 'tool:Bash', 0, 0, 0, 1)", + [], + ) + .unwrap(); + let ctx = make_pty_ctx(); + + // The turn ends. + let instance = db.get_instance_full("nova").unwrap().unwrap(); + handle_poll(&db, &ctx, "nova", &instance); + assert!(db.is_idle("nova")); + + // The leftover wake is blocked, and the row stays as Stop left it. + let (_code, stdout, ack) = userpromptsubmit_wake(&db, &ctx); + let output: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(output["decision"], "block"); + assert!(ack.is_none()); + assert_eq!( + db.get_status("nova").unwrap(), + Some((ST_LISTENING.to_string(), String::new())) + ); + + // A new message: the PTY gate must let a wake through. + insert_broadcast_from_luna(&db, "turn 6"); + assert!(db.has_pending("nova")); + let gate = crate::delivery::evaluate_gate( + &crate::delivery::ToolConfig::claude(), + &idle_prompt_delivery_state(), + db.is_idle("nova"), + ); + assert!(gate.safe, "PTY gate held the wake: {}", gate.reason); + + // That wake delivers the message. + let (_code, stdout, ack) = userpromptsubmit_wake(&db, &ctx); + assert!(stdout.contains("turn 6"), "message not delivered: {stdout}"); + common::commit_delivery_ack(&db, &ack.expect("delivery must be acknowledged")); + assert!(!db.has_pending("nova")); + } + + /// Claude's screen once it has put a blocked `` back into its input + /// box, under a typical footer, published the way the PTY proxy does it. + fn leftover_wake_delivery_state(inject_port: u16) -> crate::delivery::DeliveryState { + let patterns: Vec = crate::tool::Tool::Claude + .ready_patterns() + .iter() + .map(|p| p.to_string()) + .collect(); + let mut screen = + crate::pty::screen::ScreenTracker::new_with_instance(24, 80, &patterns, None); + let border = "\u{2500}".repeat(80); + screen.process( + format!( + "{border}\r\n\u{276F} \r\n{border}\r\n \u{23F5}\u{23F5} bypass permissions on \u{B7} 1 shell, 1 monitor \u{B7} \u{2190} 1 agent\r\n" + ) + .as_bytes(), + ); + let input_text = screen.get_input_box_text("claude"); + assert_eq!(input_text.as_deref(), Some("")); + let settled = Instant::now() + .checked_sub(std::time::Duration::from_secs(30)) + .unwrap(); + crate::delivery::DeliveryState { + screen: std::sync::Arc::new(std::sync::RwLock::new(crate::delivery::ScreenState { + ready: screen.is_ready(), + prompt_empty: screen.is_prompt_empty("claude"), + input_text, + leftover_wake: screen.is_claude_leftover_wake(), + last_user_input: settled, + last_output: settled, + ..Default::default() + })), + grok_acp: None, + launch_phase_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + inject_port, + user_activity_cooldown_ms: 3000, + } + } + + /// Stands in for Claude Code on the PTY: records every write the delivery + /// loop makes, types text into the box, and on Enter submits the box to + /// the real UserPromptSubmit hook. Returns the writes, the hook's output, + /// and whether anything was still pending right after the delivery. + fn spawn_fake_claude( + listener: std::net::TcpListener, + screen: std::sync::Arc>, + db_path: PathBuf, + running: std::sync::Arc, + notify_port: u16, + ) -> std::thread::JoinHandle<(Vec, String, bool)> { + use std::io::Read; + std::thread::spawn(move || { + let db = HcomDb::open_raw(&db_path).unwrap(); + let ctx = make_pty_ctx(); + listener.set_nonblocking(true).unwrap(); + let mut writes = Vec::new(); + let mut delivered = String::new(); + let mut pending_after = true; + let deadline = Instant::now() + std::time::Duration::from_secs(20); + while delivered.is_empty() && Instant::now() < deadline { + let mut stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + Err(e) => panic!("fake Claude accept failed: {e}"), + }; + stream.set_nonblocking(false).unwrap(); + let mut bytes = String::new(); + stream.read_to_string(&mut bytes).unwrap(); + writes.push(bytes.clone()); + let mut s = screen.write().unwrap(); + let in_box = s.input_text.clone().unwrap_or_default(); + if bytes == "\r" { + // Enter clears the box and submits what it held. + s.input_text = Some(String::new()); + s.prompt_empty = true; + s.leftover_wake = false; + drop(s); + let (_code, stdout, ack) = userpromptsubmit_prompt(&db, &ctx, &in_box); + if let Some(ack) = ack { + common::commit_delivery_ack(&db, &ack); + } + pending_after = db.has_pending("nova"); + delivered = stdout; + } else { + // Typed text lands beside whatever the box held. + s.input_text = Some(in_box + &bytes); + s.prompt_empty = false; + s.leftover_wake = false; + } + } + running.store(false, std::sync::atomic::Ordering::Release); + let _ = std::net::TcpStream::connect(("127.0.0.1", notify_port)); + (writes, delivered, pending_after) + }) + } + + /// Regression: with the blocked bare wake kept idle (previous test), + /// Claude still put the blocked `` back into its input + /// box. The next message woke the PTY loop, but the gate read hcom's own + /// trigger as a user draft: "Gate blocked: prompt_has_text" for 90 s, + /// nothing typed, until an outside monitor started a turn. Replays Stop, + /// the blocked wake, the token left in the box and a new message, then + /// runs the real delivery loop against a fake Claude. + #[test] + #[serial] + fn test_leftover_wake_in_the_prompt_is_submitted_with_enter_alone() { + crate::config::Config::init(); + let (dir, _guard, mut db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id, name_announced) + VALUES ('nova', 'sess-1', 'claude', 'active', 'tool:Bash', 0, 0, 0, 1)", + [], + ) + .unwrap(); + // The loop resolves its name through this process's binding, if any. + if let Some(process_id) = crate::config::Config::get().process_id { + db.set_process_binding(&process_id, "sess-1", "nova") + .unwrap(); + } + let ctx = make_pty_ctx(); + + // The turn ends, then a bare wake with nothing pending is blocked. + let instance = db.get_instance_full("nova").unwrap().unwrap(); + handle_poll(&db, &ctx, "nova", &instance); + let (_code, stdout, ack) = userpromptsubmit_wake(&db, &ctx); + let output: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(output["decision"], "block"); + assert!(ack.is_none()); + assert!(db.is_idle("nova")); + + // Claude puts the blocked prompt back into its box. (`ready` is false + // under this footer whatever the box holds; Claude does not gate on it.) + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let state = leftover_wake_delivery_state(listener.local_addr().unwrap().port()); + { + let screen = state.screen.read().unwrap(); + assert!(!screen.prompt_empty && screen.leftover_wake && !screen.ready); + } + + // A message arrives; the gate submits the leftover instead of guarding it. + insert_broadcast_from_luna(&db, "turn 7"); + assert!(db.has_pending("nova")); + let config = crate::delivery::ToolConfig::claude(); + let gate = crate::delivery::evaluate_gate(&config, &state, db.is_idle("nova")); + assert!( + gate.submits_leftover(), + "gate held the wake: {}", + gate.reason + ); + + // The PTY loop presses Enter on it, and the hook delivers the message. + let notify = crate::notify::NotifyServer::new().unwrap(); + let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let fake_claude = spawn_fake_claude( + listener, + state.screen.clone(), + dir.path().join(".hcom").join("test.db"), + running.clone(), + notify.port(), + ); + crate::delivery::run_delivery_loop( + running, &mut db, ¬ify, &state, "nova", &config, None, None, None, + ); + let (writes, delivered, pending_after) = fake_claude.join().unwrap(); + + assert_eq!(writes, ["\r"], "the loop must press Enter alone"); + assert!(delivered.contains("turn 7"), "not delivered: {delivered}"); + assert!(!pending_after); + } + + /// Claude also fires UserPromptSubmit for a prompt queued during a turn. + /// Blocking a bare wake there must not mark the busy agent idle. + #[test] + #[serial] + fn test_blocked_bare_wake_mid_turn_keeps_the_turn_active() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, status_time, created_at, last_event_id, name_announced) + VALUES ('nova', 'sess-1', 'claude', 'active', 'tool:Bash', 0, 0, 0, 1)", + [], + ) + .unwrap(); + + let (_code, stdout, ack) = userpromptsubmit_wake(&db, &make_pty_ctx()); + + let output: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(output["decision"], "block"); + assert!(ack.is_none()); + assert_eq!( + db.get_status("nova").unwrap(), + Some((ST_ACTIVE.to_string(), "tool:Bash".to_string())) + ); + } + /// Property: a subagent-context hook whose row *does* resolve, but which /// doesn't match any actionable branch (e.g. an ordinary Edit tool call), /// stays a silent no-op rather than falling through to parent handling. diff --git a/src/hooks/claude_mod.rs b/src/hooks/claude_mod.rs index 0acd862c..46250c1f 100644 --- a/src/hooks/claude_mod.rs +++ b/src/hooks/claude_mod.rs @@ -248,33 +248,7 @@ fn poll(args: &[String]) -> Result { .find(|pair| pair[0] == "--detail") .map(|pair| pair[1].as_str()) .unwrap_or(""); - db.with_write_scope(|| { - // Reports never count as a successful cycle, so they neither refresh - // liveness nor clear an error. - if let Some(mut current) = load(&db, session, process)? { - if event == "submission-failed" - || (event == "poll-failed" && !current.submission_failed) - { - current.error = Some(detail.chars().take(1000).collect()); - current.submission_failed = event == "submission-failed"; - db.kv_set( - &key(session, process), - Some(&serde_json::to_string(¤t)?), - )?; - } - log( - &format!("claude.mod_{event}"), - session, - process, - ¤t.name, - detail, - ); - report_health(&db, process, ¤t.name)?; - } else { - log(&format!("claude.mod_{event}"), session, process, "", detail); - } - Ok(()) - })?; + record_report(&db, session, process, event, detail)?; if let Some(name) = validated_owner(&db, session, process)? { crate::notify::wake(&db, &name, &[crate::notify::WakeKind::Plugin]); } @@ -303,6 +277,52 @@ fn poll(args: &[String]) -> Result { Ok(serde_json::json!({"wake": wake})) } +/// Records a mod report. Reports never count as a successful cycle, so they +/// neither refresh liveness nor clear an error. +fn record_report( + db: &HcomDb, + session: &str, + process: &str, + event: &str, + detail: &str, +) -> Result<()> { + db.with_write_scope(|| { + let Some(mut current) = load(db, session, process)? else { + log(&format!("claude.mod_{event}"), session, process, "", detail); + return Ok(()); + }; + // Claude holds a submitted wake until the session is idle. When a turn + // delivered the batch first (load() just saw the cursor move), the + // hook blocks the late wake as having nothing pending: no failure. + if event == "submission-failed" && current.attempted_cursor.is_none() { + log( + "claude.mod_stale_wake", + session, + process, + ¤t.name, + detail, + ); + return report_health(db, process, ¤t.name); + } + if event == "submission-failed" || (event == "poll-failed" && !current.submission_failed) { + current.error = Some(detail.chars().take(1000).collect()); + current.submission_failed = event == "submission-failed"; + db.kv_set( + &key(session, process), + Some(&serde_json::to_string(¤t)?), + )?; + } + log( + &format!("claude.mod_{event}"), + session, + process, + ¤t.name, + detail, + ); + report_health(db, process, ¤t.name) + }) +} + // Short write scopes serialize overlapping polls after a module reload. Never // hold a database lock while waiting on TCP or on Claude's prompt submission. // Loading records hook progress only; health changes belong to finish(). @@ -495,6 +515,77 @@ mod tests { ); } + #[test] + fn a_late_wake_dropped_after_the_hook_delivered_is_not_a_failure() { + let (_dir, db) = database(); + db.mark_claude_session_validated("session-1", "nova") + .unwrap(); + db.conn().execute( + "INSERT INTO events (type,timestamp,instance,data) VALUES ('message','2026-01-01T00:00:00Z','luna','{\"from\":\"luna\",\"text\":\"hello\",\"scope\":\"broadcast\"}')", [], + ).unwrap(); + finish(&db, "session-1", "process-1", false).unwrap(); + assert!(finish(&db, "session-1", "process-1", true).unwrap()); + // A turn Claude started on its own delivers the batch while the wake + // waits; the wake then comes in with nothing pending and is blocked. + db.advance_instance_cursor("nova", 1).unwrap(); + record_report( + &db, + "session-1", + "process-1", + "submission-failed", + "wake dropped", + ) + .unwrap(); + let current = state(&db, "session-1", "process-1").unwrap().unwrap(); + assert!(!current.submission_failed); + assert_eq!(current.error, None); + assert_eq!( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .status_context, + "" + ); + // The next message still gets a wake. + db.conn().execute( + "INSERT INTO events (type,timestamp,instance,data) VALUES ('message','2026-01-01T00:00:01Z','luna','{\"from\":\"luna\",\"text\":\"again\",\"scope\":\"broadcast\"}')", [], + ).unwrap(); + assert!(finish(&db, "session-1", "process-1", true).unwrap()); + } + + #[test] + fn a_dropped_wake_still_awaiting_its_hook_is_a_failure() { + let (_dir, db) = database(); + db.mark_claude_session_validated("session-1", "nova") + .unwrap(); + db.conn().execute( + "INSERT INTO events (type,timestamp,instance,data) VALUES ('message','2026-01-01T00:00:00Z','luna','{\"from\":\"luna\",\"text\":\"hello\",\"scope\":\"broadcast\"}')", [], + ).unwrap(); + finish(&db, "session-1", "process-1", false).unwrap(); + assert!(finish(&db, "session-1", "process-1", true).unwrap()); + record_report( + &db, + "session-1", + "process-1", + "submission-failed", + "wake dropped", + ) + .unwrap(); + assert!( + state(&db, "session-1", "process-1") + .unwrap() + .unwrap() + .submission_failed + ); + assert_eq!( + db.get_instance_full("nova") + .unwrap() + .unwrap() + .status_context, + "tui:mod-failed" + ); + } + #[test] fn poll_errors_clear_only_after_a_full_successful_cycle() { let (_dir, db) = database(); diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 4a1a7c57..c24418aa 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -80,6 +80,10 @@ pub mod test_helpers { "OMP_PROFILE", "PI_PROFILE", "HERMES_HOME", + // Set when the tests run inside an hcom-launched agent; they + // switch delivery loops to plugin mode. + crate::hooks::claude_mod::DELIVERY_ENV, + crate::hooks::copilot::EXTENSION_DELIVERY_ENV, ] .into_iter() .map(|key| (key, std::env::var_os(key))) diff --git a/src/pty/screen.rs b/src/pty/screen.rs index a5ba589d..54e1b0f5 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -157,6 +157,17 @@ fn sanitize_child_title(title: &str) -> String { out } +/// Claude's bordered input box as parsed from the screen. +struct ClaudeInputBox { + /// Text after the prompt glyph on the prompt row, trimmed. + text: String, + /// The text is dim placeholder styling rather than typed input. + is_placeholder: bool, + /// Only blank rows sit between the prompt row and the bottom border, so + /// the prompt row holds the whole input. + single_row: bool, +} + /// Trim whitespace including NBSP (U+00A0) from both ends fn trim_with_nbsp(s: &str) -> &str { s.trim_matches(|c: char| c.is_whitespace() || c == '\u{00A0}') @@ -820,7 +831,7 @@ impl ScreenTracker { /// Uses vt100's cell-level dim attribute to distinguish placeholder from user input. /// This enables 0.5s user_activity_cooldown (same as Gemini/Codex) instead of the /// previous 3s workaround needed when using text heuristics. - fn get_claude_input_box(&self) -> Option<(String, bool)> { + fn get_claude_input_box(&self) -> Option { let lines = self.get_screen_lines(); let num_lines = lines.len(); @@ -843,31 +854,44 @@ impl ScreenTracker { continue; } - let mut has_border_below = false; - for offset in 1..=3 { - if row_idx + offset >= num_lines { - break; - } - if lines[row_idx + offset].contains('─') { - has_border_below = true; - break; - } - } - if !has_border_below { + let Some(border_offset) = (1..=3) + .take_while(|offset| row_idx + offset < num_lines) + .find(|offset| lines[row_idx + offset].contains('─')) + else { continue; - } + }; + // Rows between the prompt row and the bottom border hold the rest + // of a multi-line draft; an empty spacer row is not part of it. + // Draft text can contain `─`, even a whole row of it, so only an + // unindented rule as wide as the top border counts as the bottom + // border here. Anything else reads as a draft. + let bottom = lines[row_idx + border_offset].trim_end(); + let single_row = bottom.starts_with('─') + && is_dash_rule(bottom) + && bottom.chars().count() == line_above.trim_end().chars().count() + && lines[row_idx + 1..row_idx + border_offset] + .iter() + .all(|row| trim_with_nbsp(row).is_empty()); let prompt_pos = line.find(prompt_char)?; let after_prompt = &line[prompt_pos + prompt_char.len_utf8()..]; let text = trim_with_nbsp(after_prompt).to_string(); if text.is_empty() { - return Some((text, false)); + return Some(ClaudeInputBox { + text, + is_placeholder: false, + single_row, + }); } let is_placeholder = self .is_dim_after_prompt(row_idx as u16, &prompt_char.to_string()) .unwrap_or(true); - return Some((text, is_placeholder)); + return Some(ClaudeInputBox { + text, + is_placeholder, + single_row, + }); } None @@ -878,15 +902,31 @@ impl ScreenTracker { /// dim text as an empty prompt. pub fn is_claude_session_switcher_visible(&self) -> bool { self.get_claude_input_box() - .is_some_and(|(text, _)| text == "describe a task for a new session") + .is_some_and(|input| input.text == "describe a task for a new session") + } + + /// True when Claude's input box holds nothing but hcom's `` wake, + /// typed as input on one row. Claude puts a prompt its UserPromptSubmit + /// hook blocked back into the box, so a bare wake the hook refused (nothing + /// was pending) stays there; so does a wake whose Enter never landed. + /// + /// `get_claude_input_text` reads only the prompt row, so a multi-line draft + /// whose first line is `` would read the same; `single_row` rules + /// that out. Any other text, ` plus more` included, is a user draft. + pub fn is_claude_leftover_wake(&self) -> bool { + self.get_claude_input_box().is_some_and(|input| { + !input.is_placeholder && input.single_row && input.text == crate::delivery::WAKE_TRIGGER + }) } fn get_claude_input_text(&self) -> Option { - self.get_claude_input_box().map( - |(text, is_placeholder)| { - if is_placeholder { String::new() } else { text } - }, - ) + self.get_claude_input_box().map(|input| { + if input.is_placeholder { + String::new() + } else { + input.text + } + }) } /// Extract Gemini input text. @@ -2672,6 +2712,62 @@ mod tests { assert_eq!(t.get_claude_input_text(), Some(String::new())); } + // ---- Claude leftover wake ---- + + /// Claude's bordered input box holding `rows` (the prompt row first). + fn claude_box(rows: &[&str]) -> ScreenTracker { + let mut t = make_tool_tracker(24, 80, crate::tool::Tool::Claude); + let border = "─".repeat(80); + let mut lines = vec![border.as_str()]; + lines.extend_from_slice(rows); + lines.push(border.as_str()); + lines.push(" ⏵⏵ bypass permissions on · 1 shell, 1 monitor · ← 1 agent"); + render_rows(&mut t, &lines); + t + } + + #[test] + fn claude_leftover_wake_is_only_a_lone_typed_trigger() { + // Claude put a blocked `` back into the box. + let t = claude_box(&["❯ "]); + assert_eq!(t.get_claude_input_text().as_deref(), Some("")); + assert!(t.is_claude_leftover_wake()); + // The bypass-permissions box uses a plain `>`. + assert!(claude_box(&["> "]).is_claude_leftover_wake()); + // The two-row box with an empty spacer row still holds only the wake. + assert!(claude_box(&["❯ ", ""]).is_claude_leftover_wake()); + + for rows in [ + &["❯ hi"][..], + &["❯ hi "], + &["❯ "], + &["❯ "], + // A multi-line draft whose first line is the trigger: the prompt + // row alone reads ``, but Enter would send the whole draft. + &["❯ ", " and the rest of my draft"], + // A second draft line holding `─` is not the bottom border. + &["❯ ", " ─ draft notes"], + &["❯ ", " ───", " unfinished user draft"], + &["❯ ", "───"], + ] { + let t = claude_box(rows); + assert!( + !t.is_claude_leftover_wake(), + "draft taken as a wake: {rows:?}" + ); + } + } + + #[test] + fn claude_dim_trigger_is_placeholder_not_a_leftover_wake() { + let mut t = make_tool_tracker(24, 80, crate::tool::Tool::Claude); + t.process(format!("{}\r\n", "─".repeat(80)).as_bytes()); + t.process("❯ \x1b[2m\x1b[0m\r\n".as_bytes()); + t.process(format!("{}\r\n", "─".repeat(80)).as_bytes()); + assert_eq!(t.get_claude_input_text(), Some(String::new())); + assert!(!t.is_claude_leftover_wake()); + } + // ---- trim_with_nbsp ---- #[test] diff --git a/src/pty/shared.rs b/src/pty/shared.rs index 1a367837..d35e24a8 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -227,6 +227,10 @@ pub(super) fn update_delivery_state( state.nav_overlay = matches!(target.known_tool(), Some(Tool::Claude)) && (screen.is_claude_subagent_nav_visible() || screen.is_claude_session_switcher_visible()); + // Claude only: a blocked bare wake comes back into the input box, and + // the gate must not mistake hcom's own trigger for a user draft. + state.leftover_wake = + matches!(target.known_tool(), Some(Tool::Claude)) && screen.is_claude_leftover_wake(); state.startup_loading = matches!(target.known_tool(), Some(Tool::Codex)) && launch_phase_active.load(Ordering::Acquire) && screen.is_codex_startup_loading(); @@ -1942,6 +1946,62 @@ No session found matching 'never-saved'" assert_eq!(calls.get(), 0, "cursor keystroke must not publish"); } + #[test] + fn claude_gate_submits_only_a_lone_leftover_wake() { + // Observed: Claude put a blocked bare `` back into its + // box, and the gate guarded it as a draft ("prompt_has_text") while + // messages queued. From the screen through the published state to the + // gate: the lone trigger is submitted, anything else is protected. + let patterns: Vec = Tool::Claude + .ready_patterns() + .iter() + .map(|p| p.to_string()) + .collect(); + let config = crate::delivery::ToolConfig::claude(); + let border = "\u{2500}".repeat(80); + let gate_for = |rows: &[&str]| { + let mut screen = ScreenTracker::new_with_instance(24, 80, &patterns, None); + let mut frame = format!("\x1b[2J\x1b[H{border}\r\n"); + for row in rows { + frame.push_str(&format!("{row}\r\n")); + } + frame.push_str(&format!("{border}\r\n \u{23F8} manual mode on\r\n")); + screen.process(frame.as_bytes()); + let state = Arc::new(RwLock::new(ScreenState::default())); + let launch = Arc::new(AtomicBool::new(false)); + let target = PtyTarget::Known(Tool::Claude); + update_delivery_state(&state, &screen, &target, &launch, &|_| {}); + { + let mut s = state.write().unwrap(); + s.last_user_input = Instant::now() - Duration::from_secs(30); + } + let delivery = crate::delivery::DeliveryState { + screen: state, + grok_acp: None, + launch_phase_active: launch, + inject_port: 0, + user_activity_cooldown_ms: 3000, + }; + let gate = crate::delivery::evaluate_gate(&config, &delivery, true); + (gate.safe, gate.reason, gate.submits_leftover()) + }; + assert_eq!( + gate_for(&["\u{276F} "]), + (true, "leftover_wake", true) + ); + assert_eq!(gate_for(&["\u{276F} "]), (true, "ok", false)); + for rows in [ + &["\u{276F} hi"][..], + &["\u{276F} ", " second line of a draft"], + ] { + assert_eq!( + gate_for(rows), + (false, "prompt_has_text", false), + "{rows:?}" + ); + } + } + #[test] fn approval_clears_while_output_keeps_churning() { // Codex's /voice meters redraw continuously, so output is never quiet.