Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/claude_mod/hooks/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<hcom>', 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));
},
);
Expand Down
25 changes: 25 additions & 0 deletions src/claude_mod/tests/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 173 additions & 4 deletions src/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<hcom>";

/// Human-readable descriptions for gate block reasons.
pub(crate) fn gate_block_detail(reason: &str) -> &'static str {
match reason {
Expand Down Expand Up @@ -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 `<hcom>` wake is submitted with Enter
/// rather than waited on (`ScreenState::leftover_wake`).
#[derive(Clone)]
pub struct ToolConfig {
/// Tool name (claude, gemini, codex)
Expand Down Expand Up @@ -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 `<hcom>` 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<std::sync::RwLock<ScreenState>>,
Expand Down Expand Up @@ -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 `<hcom>` 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.
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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 `<hcom>` 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").
Expand Down Expand Up @@ -1035,6 +1061,16 @@ pub(crate) fn evaluate_gate(
};
}
if config.require_prompt_empty && !screen.prompt_empty {
// Our own `<hcom>`, 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",
Expand Down Expand Up @@ -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 `<hcom>` 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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2032,6 +2080,24 @@ pub fn run_delivery_loop(
continue;
}

// The prompt already holds our `<hcom>`: 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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.
Expand All @@ -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) => "<hcom>".to_string(),
| Some(Tool::Pi) | Some(Tool::Omp) => WAKE_TRIGGER.to_string(),
_ => build_wake_inject_text(db, &current_name, input_box_width),
};

Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2802,6 +2869,7 @@ mod tests {
approval_scrape_latched: false,
approval_scrape_negative_since: None,
nav_overlay: false,
leftover_wake: false,
startup_loading: false,
}
}
Expand Down Expand Up @@ -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("<hcom>", 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("<hcom> 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("<hcom>", 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("<hcom>", 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("<hcom>"), WAKE_TRIGGER, Duration::ZERO),
Phase1Decision::Rendered
);
assert_eq!(
phase1_decision(Some("<hcom> 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 `<hcom>`, but the scrape no longer sees a
// lone wake. Neither the first Enter nor a retry may submit it.
let draft = claude_prompt("<hcom>", false);
assert_eq!(
submit_ownership(&draft, WAKE_TRIGGER, true),
PromptOwnership::Mixed
);
assert_eq!(
submit_ownership(&claude_prompt("<hcom>", 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();
Expand Down
Loading
Loading