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:
müde 2026-05-15 19:45:37 +02:00
parent de09503b59
commit 300be8afa9
6 changed files with 83 additions and 3 deletions

View file

@ -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;

View file

@ -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.

View file

@ -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">