operator control: /cancel slash command + cancel button
new POST /api/cancel on the per-agent web UI: shells out pkill -INT claude (procps added to harness-base.nix). emits a Note on the bus so the operator sees the cancel landed; state goes back to idle when run_claude wakes and emits TurnEnd as usual. frontend: - /cancel slash command in the terminal input - ■ cancel turn button in the state row, visible only while state === 'thinking' (driven from the same SSE-based state machine). disabled briefly during the POST. claude gets SIGINT (not TERM) so it flushes anything in-flight and emits a final result row before exiting.
This commit is contained in:
parent
de09503b59
commit
300be8afa9
6 changed files with 83 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
<div id="state-row">
|
||||
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
|
|
|
|||
|
|
@ -194,4 +194,3 @@ async fn inbox_unread(socket: &Path) -> u64 {
|
|||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState>) -> 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<AppState>) -> 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue