From c9647f4106fec841273382cb0916d6ad491aceca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 19:56:53 +0200 Subject: [PATCH] operator control: /compact slash command + endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new POST /api/compact on the per-agent web UI: spawns turn::compact_session in the background so the http handler returns immediately. claude runs '/compact' over the persistent --continue session; output streams into the live panel like any other turn. slash command /compact wired to the new endpoint. SLASH_COMMANDS list now lists all four (/help /clear /cancel /compact). postCancelTurn + postCompact share a postSimple() helper. deliberately not gated against an in-flight turn — claude's own session lock will reject a concurrent compact and the failure surfaces as a Note in the live panel. --- hive-ag3nt/assets/app.js | 20 +++++++++++++------- hive-ag3nt/src/web_ui.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index a2f620f..0a40d59 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -161,23 +161,26 @@ let termAPI = null; const SLASH_COMMANDS = [ - { name: '/help', desc: 'list slash commands' }, - { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, - { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/help', desc: 'list slash commands' }, + { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/compact', desc: 'compact the persistent claude session' }, ]; - async function postCancelTurn() { + async function postSimple(url, label) { try { - const resp = await fetch('/api/cancel', { method: 'POST', redirect: 'manual' }); + const resp = await fetch(url, { 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); + termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status); } } catch (err) { - if (termAPI) termAPI.row('turn-end-fail', '✗ /cancel failed: ' + err); + if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); } } + const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); + const postCompact = () => postSimple('/api/compact', '/compact'); function handleSlashCommand(line) { if (!termAPI) return false; @@ -198,6 +201,9 @@ case '/cancel': postCancelTurn(); return true; + case '/compact': + postCompact(); + return true; default: termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); return true; diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 3f7bb78..e0ea25f 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -80,6 +80,7 @@ pub async fn serve( .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) .route("/api/cancel", post(post_cancel_turn)) + .route("/api/compact", post(post_compact)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -274,6 +275,37 @@ async fn post_login_cancel(State(state): State) -> Response { Redirect::to("/").into_response() } +/// Operator-initiated session compaction. Spawns `turn::compact_session` +/// in the background — the HTTP handler returns immediately so the +/// async-form spinner can clear. Output (claude's compaction stream, +/// the "/compact done" note) lands in the live event panel like any +/// other turn. If a regular turn is in flight, claude's own session +/// lock will reject this one and we surface the error as a Note. +async fn post_compact(State(state): State) -> Response { + let bus = state.bus.clone(); + let socket = state.socket.clone(); + tokio::spawn(async move { + bus.emit(crate::events::LiveEvent::Note( + "operator: /compact — running on persistent session".into(), + )); + let settings = match crate::turn::write_settings(&socket).await { + Ok(p) => p, + Err(e) => { + bus.emit(crate::events::LiveEvent::Note(format!( + "/compact failed: settings write — {e:#}" + ))); + return; + } + }; + if let Err(e) = crate::turn::compact_session(&settings, &bus).await { + bus.emit(crate::events::LiveEvent::Note(format!( + "/compact failed: {e:#}" + ))); + } + }); + 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