diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 249daa5..013fc82 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -126,6 +126,26 @@ pre.diff { } #state-row { margin: 0.4em 0 0.2em; + display: flex; + align-items: center; + gap: 0.6em; +} +.btn-cancel-turn { + font-family: inherit; + font-size: 0.8em; + letter-spacing: 0.08em; + background: transparent; + color: var(--red); + border: 1px solid var(--red); + border-radius: 999px; + padding: 0.2em 0.8em; + cursor: pointer; + text-shadow: 0 0 4px currentColor; + transition: box-shadow 0.15s ease, background 0.15s ease; +} +.btn-cancel-turn:hover { + background: rgba(243, 139, 168, 0.1); + box-shadow: 0 0 10px -2px currentColor; } .state-badge { display: inline-block; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 6183e93..a2f620f 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -161,10 +161,24 @@ let termAPI = null; const SLASH_COMMANDS = [ - { name: '/help', desc: 'list slash commands' }, - { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/help', desc: 'list slash commands' }, + { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, ]; + async function postCancelTurn() { + try { + const resp = await fetch('/api/cancel', { method: 'POST', redirect: 'manual' }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok && termAPI) { + termAPI.row('turn-end-fail', '✗ /cancel failed: http ' + resp.status); + } + } catch (err) { + if (termAPI) termAPI.row('turn-end-fail', '✗ /cancel failed: ' + err); + } + } + function handleSlashCommand(line) { if (!termAPI) return false; const trimmed = line.trim(); @@ -181,6 +195,9 @@ termAPI.clear(); termAPI.row('note', '· terminal cleared (local view only — server history kept)'); return true; + case '/cancel': + postCancelTurn(); + return true; default: termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); return true; @@ -282,6 +299,8 @@ const age = fmtAge(Date.now() - stateSince); badge.textContent = def.glyph + ' ' + def.text + ' · ' + age; badge.className = 'state-badge state-' + stateName; + const cancelBtn = $('cancel-btn'); + if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; } function setState(next) { if (next === stateName) return; @@ -302,6 +321,16 @@ } startStateTicker(); + // Wire the cancel-turn button (visible only while state === thinking). + (() => { + const btn = $('cancel-btn'); + if (!btn) return; + btn.addEventListener('click', () => { + btn.disabled = true; + postCancelTurn().finally(() => { btn.disabled = false; }); + }); + })(); + // Track banner activity by reference-counting in-flight turns. A turn // can begin while the previous turn_end is still in the pipeline (rare // but happens on tight wake cycles), so we count rather than toggle. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 219f8d9..397fd32 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -15,6 +15,7 @@
… booting +
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index f291cd5..bddee64 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -194,4 +194,3 @@ async fn inbox_unread(socket: &Path) -> u64 { _ => 0, } } - diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 4e5c120..3f7bb78 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -79,6 +79,7 @@ pub async fn serve( .route("/login/start", post(post_login_start)) .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) + .route("/api/cancel", post(post_cancel_turn)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -273,6 +274,33 @@ async fn post_login_cancel(State(state): State) -> Response { Redirect::to("/").into_response() } +/// Cancel the in-flight claude turn. Coarse-grained: shells out +/// `pkill -INT claude` since there's at most one claude per container. +/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a +/// final result row. Emits a Note so the operator sees the cancel +/// landed; the actual state transition back to `idle` happens when +/// `run_claude` wakes up and the harness emits `TurnEnd`. +async fn post_cancel_turn(State(state): State) -> Response { + let out = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await; + let note = match out { + Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(), + Ok(o) if o.status.code() == Some(1) => { + "operator: /cancel — no claude process to interrupt".to_owned() + } + Ok(o) => format!( + "operator: /cancel — pkill exited {} stderr={}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => format!("operator: /cancel — pkill failed: {e}"), + }; + state.bus.emit(crate::events::LiveEvent::Note(note)); + Redirect::to("/").into_response() +} + fn error_response(message: &str) -> Response { // Plain text — JS app surfaces in `alert()`, HTML wrapping would just // be noise. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 814e605..a612a32 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -14,6 +14,9 @@ claude-code bashInteractive coreutils-full + # procps for pkill — used by the web UI's /api/cancel to SIGINT the + # in-flight claude turn. + procps ]; # Git is needed by claude's Bash tool (for the agent <-> manager config