From c787d0427f6f974a0608adae397677636acf7b51 Mon Sep 17 00:00:00 2001 From: signet-bot-claude <261785442+signet-bot-claude@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:39:12 -0400 Subject: [PATCH 1/8] Claude: keep a blocked bare wake idle so the next message still wakes the agent UserPromptSubmit set status active/prompt before blocking a bare `` wake that found nothing pending. A blocked prompt runs no turn, so no Stop hook follows, and the row stayed active. The PTY gate then held every later message at `not_idle`. Stability recovery needs 10 s of quiet screen output, and that never came while a background agent was running. Observed in a live session: a background task's notification started a turn just as the PTY typed ``. Claude queued the wake, and that turn's own UserPromptSubmit delivered the pending batch. Stop set listening, and 85 ms later Claude submitted the queued ``. The hook found nothing pending, set active/prompt, and blocked the prompt. Every later message logged "Gate blocked: not_idle" until a manual wake 4.5 minutes later, by which time 10 messages were unread. In PTY mode a blocked bare wake now sets the status Stop sets (listening, ""), then checks for pending messages again. A message that arrived after the first check is delivered by this wake rather than blocked. Any later one finds the instance idle and gets its own wake. Without the PTY wrapper nothing injects wakes, so the status stays as Stop's poll left it rather than being marked active for a turn that never runs. Tests: after Stop and a blocked bare wake the row is listening, the Claude PTY gate passes for a new message, and the next wake delivers it (this fails on the old code with active/prompt). A message that lands between the first check and the block is delivered by that wake. Co-Authored-By: Claude Opus 5.5 --- src/hooks/claude.rs | 237 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 215 insertions(+), 22 deletions(-) diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index bfa1e08d..8c65fdda 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1857,48 +1857,100 @@ fn handle_userpromptsubmit( if ctx.is_pty_mode && let Some(prepared) = common::prepare_pending_messages(db, instance_name) { - let user_display = - common::format_hook_messages_for_instance(db, &prepared.messages, instance_name); - let model_context = - common::format_messages_json_for_instance(db, &prepared.messages, instance_name); - + let (user_display, model_context) = + format_userpromptsubmit_delivery(db, &prepared, instance_name); system_message = Some(user_display); contexts.push(model_context); delivery_ack = Some(prepared.ack); } if !contexts.is_empty() { - let mut output = serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": contexts.join("\n\n---\n\n"), - }, - }); - if let Some(message) = system_message { - output - .as_object_mut() - .unwrap() - .insert("systemMessage".into(), Value::String(message)); - } if delivery_ack.is_none() { lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); } return ( 0, - serde_json::to_string(&output).unwrap_or_default(), + userpromptsubmit_context_output(&contexts, system_message), delivery_ack, ); } - lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); 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.", - ); + return block_empty_bare_hcom_wake(db, ctx, instance_name); } + lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); (0, String::new(), None) } +/// User-facing display and model context for a UserPromptSubmit delivery. +fn format_userpromptsubmit_delivery( + db: &HcomDb, + prepared: &common::PreparedDelivery, + instance_name: &str, +) -> (String, String) { + ( + common::format_hook_messages_for_instance(db, &prepared.messages, instance_name), + common::format_messages_json_for_instance(db, &prepared.messages, instance_name), + ) +} + +/// UserPromptSubmit stdout that adds hcom context (bootstrap and/or messages) +/// to the turn the prompt starts. +fn userpromptsubmit_context_output(contexts: &[String], system_message: Option) -> String { + let mut output = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": contexts.join("\n\n---\n\n"), + }, + }); + if let Some(message) = system_message { + output + .as_object_mut() + .unwrap() + .insert("systemMessage".into(), Value::String(message)); + } + serde_json::to_string(&output).unwrap_or_default() +} + +/// A bare `` wake found nothing to deliver, so another hook already +/// handled the batch it was typed for. The usual source is a wake Claude +/// queued: the PTY injected it just as a turn hcom could not see coming (a +/// background task's notification) started, that turn's own hook delivered +/// the batch, and Claude submitted the leftover `` when the turn ended, +/// right after Stop. +/// +/// Blocking the prompt means no turn runs, so no Stop hook follows. The +/// instance therefore stays idle exactly as Stop leaves it. Marking it active +/// here held the PTY gate at `not_idle`, and later messages queued unread +/// until something else woke the session. +fn block_empty_bare_hcom_wake( + db: &HcomDb, + ctx: &HcomContext, + instance_name: &str, +) -> (i32, String, Option) { + // Without the PTY wrapper nothing injects wakes or gates on this status; + // leave whatever Stop's poll last set. + if ctx.is_pty_mode { + lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); + // Publish idle first, then look again: a message that landed after + // the first check is delivered by this wake, and any later one finds + // the instance idle and gets a wake of its own. + if let Some(prepared) = common::prepare_pending_messages(db, instance_name) { + let (user_display, model_context) = + format_userpromptsubmit_delivery(db, &prepared, instance_name); + return ( + 0, + userpromptsubmit_context_output(&[model_context], Some(user_display)), + Some(prepared.ack), + ); + } + common::notify_hook_instance_with_db(db, instance_name); + } + block_bare_hcom_wake( + "This hcom wake arrived after another hook handled the queued delivery. Nothing is pending; no action is needed.", + ) +} + /// Parent PermissionRequest: mark instance blocked immediately on approval UI. fn handle_permission_request( db: &HcomDb, @@ -5232,6 +5284,147 @@ 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) { + let payload = HookPayload::from_claude(serde_json::json!({ + "session_id": "sess-1", + "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")); + } + + /// A message that lands after the wake's first check, before it would be + /// blocked, rides this wake instead of waiting for another one. + #[test] + #[serial] + fn test_bare_wake_delivers_a_message_that_lands_before_it_blocks() { + 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', 'listening', '', 0, 0, 0, 1)", + [], + ) + .unwrap(); + let ctx = make_pty_ctx(); + + // The first check found nothing; the message arrives now. + insert_broadcast_from_luna(&db, "late"); + let (_code, stdout, ack) = block_empty_bare_hcom_wake(&db, &ctx, "nova"); + + let output: Value = serde_json::from_str(&stdout).unwrap(); + assert!( + output.get("decision").is_none(), + "wake was blocked: {stdout}" + ); + assert!(stdout.contains("late"), "message not delivered: {stdout}"); + common::commit_delivery_ack(&db, &ack.expect("delivery must be acknowledged")); + assert!(!db.has_pending("nova")); + assert_eq!(db.get_status("nova").unwrap().unwrap().0, ST_ACTIVE); + } + /// 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. From 2eb4ef2ae417f9dfe911e36aebff04838185f8e7 Mon Sep 17 00:00:00 2001 From: signet-bot-claude <261785442+signet-bot-claude@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:39:12 -0400 Subject: [PATCH 2/8] Claude: submit a leftover wake instead of guarding it as a draft When Claude's UserPromptSubmit hook blocks a bare wake (nothing pending), Claude puts the blocked prompt back into its input box. The PTY gate then read hcom's own trigger as user text and held every later message at prompt_has_text (observed: 73 blocked attempts over 90 s, nothing typed, until an outside monitor started a turn). The screen scrape now reports when Claude's box holds only , typed on one row (ScreenTracker::is_claude_leftover_wake). With that, the gate opens with reason leftover_wake and the delivery loop presses Enter alone: the render phase re-checks ownership, approval and typing before the Enter, and the UserPromptSubmit hook delivers the pending batch as usual. Any other text stays protected, including " plus more" and a multi-line draft whose first line is . Every earlier gate (idle, approval, typing, overlays, submit settle) still applies. Tests: the scrape recognizes only a lone, typed, single-row (not dim placeholder text, not a longer or multi-line draft); the gate submits it but still waits for idle and for the user; and an end-to-end hook test where the leftover is submitted with Enter alone and the batch is delivered. Co-Authored-By: Claude Opus 5.5 --- src/delivery.rs | 138 ++++++++++++++++++++++++++++++++- src/hooks/claude.rs | 184 +++++++++++++++++++++++++++++++++++++++++++- src/pty/screen.rs | 127 +++++++++++++++++++++++++----- src/pty/shared.rs | 60 +++++++++++++++ 4 files changed, 485 insertions(+), 24 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index 383d0dc3..07c9636e 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>, @@ -871,6 +887,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. @@ -891,6 +914,7 @@ impl Default for ScreenState { last_prompt_submit: None, approval_scrape_latched: false, nav_overlay: false, + leftover_wake: false, startup_loading: false, } } @@ -958,7 +982,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"). @@ -1021,6 +1047,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", @@ -2018,6 +2054,23 @@ 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(); + 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. @@ -2030,7 +2083,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), }; @@ -2787,6 +2840,7 @@ mod tests { last_prompt_submit: None, approval_scrape_latched: false, nav_overlay: false, + leftover_wake: false, startup_loading: false, } } @@ -3240,6 +3294,86 @@ 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 + ); + } + 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 8c65fdda..4c836b0b 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -5327,9 +5327,17 @@ mod tests { } 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": prompt, })); let instance = db.get_instance_full("nova").unwrap().unwrap(); handle_userpromptsubmit( @@ -5394,6 +5402,180 @@ mod tests { 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); + } + /// A message that lands after the wake's first check, before it would be /// blocked, rides this wake instead of waiting for another one. #[test] diff --git a/src/pty/screen.rs b/src/pty/screen.rs index a5ba589d..8f3d8501 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,37 @@ 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. + let single_row = 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 +895,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 +2705,58 @@ 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"], + ] { + 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 8fcb503c..06d4a1f4 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -219,6 +219,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(); @@ -1934,6 +1938,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 codex_input_preserves_approval_until_screen_clears() { let target = PtyTarget::Known(Tool::Codex); From f64705452f1d8127a2179c3eecd3e3334d57346d Mon Sep 17 00:00:00 2001 From: signet-bot-claude <261785442+signet-bot-claude@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:42:46 -0400 Subject: [PATCH 3/8] Claude: submit a leftover wake only while it still sits alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the leftover-wake gate could submit a user's unfinished draft: The first Enter and every WaitTextClear retry checked only that the prompt row reads . That stays true after the user adds a second line, so a draft starting with the trigger could be submitted once typing paused. A leftover-wake submission now also needs the scrape's leftover_wake before each Enter; otherwise delivery goes back to Pending. The bottom border was taken to be the first row below the prompt containing a box-drawing dash, so a draft line such as " ─ draft notes" passed as the border and the prompt as a single row. The border row must now be a full dash rule, so such a draft fails closed. Co-Authored-By: Claude Opus 5.5 --- src/delivery.rs | 39 +++++++++++++++++++++++++++++++++++++-- src/pty/screen.rs | 11 ++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index 07c9636e..64a60ade 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1381,6 +1381,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, @@ -1868,6 +1879,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 @@ -2065,6 +2077,7 @@ pub fn run_delivery_loop( "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; @@ -2099,6 +2112,7 @@ pub fn run_delivery_loop( ), ); injected_text = text; + submitting_leftover = false; phase_started_at = Instant::now(); enter_attempt = 0; delivery_state = State::WaitTextRender; @@ -2285,7 +2299,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 { @@ -2384,7 +2398,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 { @@ -3374,6 +3388,27 @@ mod tests { ); } + #[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/pty/screen.rs b/src/pty/screen.rs index 8f3d8501..0b73ff08 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -862,9 +862,12 @@ impl ScreenTracker { }; // 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. - let single_row = lines[row_idx + 1..row_idx + border_offset] - .iter() - .all(|row| trim_with_nbsp(row).is_empty()); + // Draft text can contain `─`, so only a full rule counts as the + // bottom border here. + let single_row = is_dash_rule(&lines[row_idx + border_offset]) + && 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()..]; @@ -2738,6 +2741,8 @@ mod tests { // 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"], ] { let t = claude_box(rows); assert!( From 1e10ee5e3d0328dc6eeb4e414c82a4ce1b4f0ac1 Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 03:16:27 +0100 Subject: [PATCH 4/8] Claude: leave the status alone when blocking an empty bare wake Claude also fires UserPromptSubmit for a prompt queued during a turn, so setting listening there marked a busy agent idle until its next tool hook. A blocked wake runs no turn: after Stop the row is already listening, and mid-turn it keeps the turn's own status. The re-check and notify are no longer needed, since an idle row lets any later message wake on its own. --- src/hooks/claude.rs | 122 ++++++++++++-------------------------------- 1 file changed, 34 insertions(+), 88 deletions(-) diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 4c836b0b..37a7beb9 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1857,100 +1857,51 @@ fn handle_userpromptsubmit( if ctx.is_pty_mode && let Some(prepared) = common::prepare_pending_messages(db, instance_name) { - let (user_display, model_context) = - format_userpromptsubmit_delivery(db, &prepared, instance_name); + let user_display = + common::format_hook_messages_for_instance(db, &prepared.messages, instance_name); + let model_context = + common::format_messages_json_for_instance(db, &prepared.messages, instance_name); + system_message = Some(user_display); contexts.push(model_context); delivery_ack = Some(prepared.ack); } if !contexts.is_empty() { + let mut output = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": contexts.join("\n\n---\n\n"), + }, + }); + if let Some(message) = system_message { + output + .as_object_mut() + .unwrap() + .insert("systemMessage".into(), Value::String(message)); + } if delivery_ack.is_none() { lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); } return ( 0, - userpromptsubmit_context_output(&contexts, system_message), + serde_json::to_string(&output).unwrap_or_default(), delivery_ack, ); } + // 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_empty_bare_hcom_wake(db, ctx, instance_name); + 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) } -/// User-facing display and model context for a UserPromptSubmit delivery. -fn format_userpromptsubmit_delivery( - db: &HcomDb, - prepared: &common::PreparedDelivery, - instance_name: &str, -) -> (String, String) { - ( - common::format_hook_messages_for_instance(db, &prepared.messages, instance_name), - common::format_messages_json_for_instance(db, &prepared.messages, instance_name), - ) -} - -/// UserPromptSubmit stdout that adds hcom context (bootstrap and/or messages) -/// to the turn the prompt starts. -fn userpromptsubmit_context_output(contexts: &[String], system_message: Option) -> String { - let mut output = serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": contexts.join("\n\n---\n\n"), - }, - }); - if let Some(message) = system_message { - output - .as_object_mut() - .unwrap() - .insert("systemMessage".into(), Value::String(message)); - } - serde_json::to_string(&output).unwrap_or_default() -} - -/// A bare `` wake found nothing to deliver, so another hook already -/// handled the batch it was typed for. The usual source is a wake Claude -/// queued: the PTY injected it just as a turn hcom could not see coming (a -/// background task's notification) started, that turn's own hook delivered -/// the batch, and Claude submitted the leftover `` when the turn ended, -/// right after Stop. -/// -/// Blocking the prompt means no turn runs, so no Stop hook follows. The -/// instance therefore stays idle exactly as Stop leaves it. Marking it active -/// here held the PTY gate at `not_idle`, and later messages queued unread -/// until something else woke the session. -fn block_empty_bare_hcom_wake( - db: &HcomDb, - ctx: &HcomContext, - instance_name: &str, -) -> (i32, String, Option) { - // Without the PTY wrapper nothing injects wakes or gates on this status; - // leave whatever Stop's poll last set. - if ctx.is_pty_mode { - lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); - // Publish idle first, then look again: a message that landed after - // the first check is delivered by this wake, and any later one finds - // the instance idle and gets a wake of its own. - if let Some(prepared) = common::prepare_pending_messages(db, instance_name) { - let (user_display, model_context) = - format_userpromptsubmit_delivery(db, &prepared, instance_name); - return ( - 0, - userpromptsubmit_context_output(&[model_context], Some(user_display)), - Some(prepared.ack), - ); - } - common::notify_hook_instance_with_db(db, instance_name); - } - block_bare_hcom_wake( - "This hcom wake arrived after another hook handled the queued delivery. Nothing is pending; no action is needed.", - ) -} - /// Parent PermissionRequest: mark instance blocked immediately on approval UI. fn handle_permission_request( db: &HcomDb, @@ -5576,35 +5527,30 @@ mod tests { assert!(!pending_after); } - /// A message that lands after the wake's first check, before it would be - /// blocked, rides this wake instead of waiting for another one. + /// 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_bare_wake_delivers_a_message_that_lands_before_it_blocks() { + 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', 'listening', '', 0, 0, 0, 1)", + VALUES ('nova', 'sess-1', 'claude', 'active', 'tool:Bash', 0, 0, 0, 1)", [], ) .unwrap(); - let ctx = make_pty_ctx(); - // The first check found nothing; the message arrives now. - insert_broadcast_from_luna(&db, "late"); - let (_code, stdout, ack) = block_empty_bare_hcom_wake(&db, &ctx, "nova"); + let (_code, stdout, ack) = userpromptsubmit_wake(&db, &make_pty_ctx()); let output: Value = serde_json::from_str(&stdout).unwrap(); - assert!( - output.get("decision").is_none(), - "wake was blocked: {stdout}" + 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())) ); - assert!(stdout.contains("late"), "message not delivered: {stdout}"); - common::commit_delivery_ack(&db, &ack.expect("delivery must be acknowledged")); - assert!(!db.has_pending("nova")); - assert_eq!(db.get_status("nova").unwrap().unwrap().0, ST_ACTIVE); } /// Property: a subagent-context hook whose row *does* resolve, but which From 01757185394c063c784c7be62f47833fb94d21f3 Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 03:16:28 +0100 Subject: [PATCH 5/8] Claude mod: a late wake dropped after its batch was delivered is not a failure Claude holds $.prompt.submit until the session is idle. When a turn delivers the batch first, the hook blocks the late wake as having nothing pending, and the mod reported it as submission-failed. That set submission_failed after attempted_cursor had already cleared, so nothing ever cleared it: no further wakes, and tui:mod-failed advising a switch to inject. A drop that arrives once the hook has acknowledged the wake is now logged as claude.mod_stale_wake. --- src/hooks/claude_mod.rs | 145 ++++++++++++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 27 deletions(-) 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(); From 503d21a989366e9efea6f59ceae922d1f16894bd Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 03:16:28 +0100 Subject: [PATCH 6/8] Tests: clear inherited delivery env in isolated hook tests Run inside an hcom-launched agent, HCOM_CLAUDE_DELIVERY=native put the delivery loop in plugin mode and failed the leftover-wake end-to-end test. --- src/hooks/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) 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))) From 3471a730efc7cb8ce4ce637b627884ebf2b1be1b Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 03:27:59 +0100 Subject: [PATCH 7/8] Claude: only a full-width bottom border makes a leftover wake a single row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_dash_rule trims, so an indented `───` row in a draft passed as the composer's bottom border, and a pending message pressed Enter on the whole unfinished draft. The border must now be unindented and as wide as the top border; anything else reads as a draft and is left alone. --- src/pty/screen.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 0b73ff08..54e1b0f5 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -862,9 +862,13 @@ impl ScreenTracker { }; // 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 `─`, so only a full rule counts as the - // bottom border here. - let single_row = is_dash_rule(&lines[row_idx + border_offset]) + // 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()); @@ -2743,6 +2747,8 @@ mod tests { &["❯ ", " 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!( From fa132bbf7d89c83664897c2beec1b6127fb0fc46 Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 03:42:46 +0100 Subject: [PATCH 8/8] Claude mod: keep observing until a finished wake's outcome is reported The callback cleared `submitting` before awaiting the report, so a poll in between could claim a wake for a newer message. hcom then judged the old drop against that newer claim and showed tui:mod-failed for a healthy wake. --- src/claude_mod/hooks/register.js | 10 ++++++---- src/claude_mod/tests/register.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) 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