force fresh session: ↻ new session button + /new-session

bus carries a one-shot AtomicBool armed by POST
/api/new-session (or the /new-session slash command). next
turn drops --continue, starting a fresh claude session; the
flag clears automatically so subsequent turns resume normal
behavior. /compact still always uses --continue — compacting
a non-existent session is a no-op anyway.

per-agent page grows an ↻ new session button next to the
cancel-turn one (always visible, amber, confirms before
posting since dropping --continue context isn't reversible).
slash-command surface picks up /new-session for parity with
the button. note row emitted on the live feed both at arm-
time and again when the turn actually consumes the flag, so
the operator can confirm it landed.
This commit is contained in:
müde 2026-05-16 00:44:45 +02:00
parent 14aa7c7acc
commit 034b4fde10
6 changed files with 102 additions and 6 deletions

View file

@ -212,6 +212,27 @@ pre.diff {
background: rgba(243, 139, 168, 0.1); background: rgba(243, 139, 168, 0.1);
box-shadow: 0 0 10px -2px currentColor; box-shadow: 0 0 10px -2px currentColor;
} }
.btn-new-session {
font-family: inherit;
font-size: 0.8em;
letter-spacing: 0.08em;
background: transparent;
color: var(--amber);
border: 1px solid var(--amber);
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-new-session:hover {
background: rgba(250, 179, 135, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.btn-new-session:disabled {
opacity: 0.4;
cursor: progress;
}
.state-badge { .state-badge {
display: inline-block; display: inline-block;
padding: 0.25em 0.8em; padding: 0.25em 0.8em;

View file

@ -170,11 +170,12 @@
let termAPI = null; let termAPI = null;
const SLASH_COMMANDS = [ const SLASH_COMMANDS = [
{ name: '/help', desc: 'list slash commands' }, { name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' }, { name: '/clear', desc: 'wipe the terminal panel (local-only)' },
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, { name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
{ name: '/compact', desc: 'compact the persistent claude session' }, { name: '/compact', desc: 'compact the persistent claude session' },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' }, { name: '/model', desc: '/model <name> — switch claude model for future turns' },
{ name: '/new-session', desc: 'next turn runs without --continue (fresh claude session)' },
]; ];
async function postModel(name) { async function postModel(name) {
@ -213,6 +214,7 @@
} }
const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
const postCompact = () => postSimple('/api/compact', '/compact'); const postCompact = () => postSimple('/api/compact', '/compact');
const postNewSession = () => postSimple('/api/new-session', '/new-session');
function handleSlashCommand(line) { function handleSlashCommand(line) {
if (!termAPI) return false; if (!termAPI) return false;
@ -236,6 +238,11 @@
case '/compact': case '/compact':
postCompact(); postCompact();
return true; return true;
case '/new-session':
if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) {
postNewSession();
}
return true;
case '/model': { case '/model': {
const parts = trimmed.split(/\s+/); const parts = trimmed.split(/\s+/);
if (parts.length < 2 || !parts[1]) { if (parts.length < 2 || !parts[1]) {
@ -431,6 +438,19 @@
}); });
})(); })();
// Wire the new-session button (always visible; arms a one-shot for
// the next turn). Mildly destructive (drops --continue context) so
// we confirm before posting.
(() => {
const btn = $('new-session-btn');
if (!btn) return;
btn.addEventListener('click', () => {
if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return;
btn.disabled = true;
postNewSession().finally(() => { btn.disabled = false; });
});
})();
// Track banner activity by reference-counting in-flight turns. A turn // Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare // 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. // but happens on tight wake cycles), so we count rather than toggle.

View file

@ -18,6 +18,8 @@
<span id="model-chip" class="model-chip" hidden></span> <span id="model-chip" class="model-chip" hidden></span>
<span id="last-turn" class="last-turn" hidden></span> <span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button> <button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
<button type="button" id="new-session-btn" class="btn-new-session"
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
</div> </div>
<details id="inbox-section" class="agent-inbox" hidden> <details id="inbox-section" class="agent-inbox" hidden>

View file

@ -9,6 +9,7 @@
//! showing "connecting…" until the first event arrives. //! showing "connecting…" until the first event arrives.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
@ -189,6 +190,13 @@ pub struct Bus {
/// Model name passed to `claude --model`. Default `haiku`; the /// Model name passed to `claude --model`. Default `haiku`; the
/// operator can override at runtime via `POST /api/model`. /// operator can override at runtime via `POST /api/model`.
model: Arc<Mutex<String>>, model: Arc<Mutex<String>>,
/// One-shot: next `run_claude` call drops `--continue`, starting
/// a fresh claude session. Set by `POST /api/new-session` from
/// the per-agent web UI; consumed (cleared back to false) by the
/// next turn. Subsequent turns resume normal `--continue`
/// behavior. Atomic so the consumer can take-and-clear without a
/// lock.
skip_continue_once: Arc<AtomicBool>,
} }
impl Bus { impl Bus {
@ -213,9 +221,24 @@ impl Bus {
store, store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))), state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(initial_model)), model: Arc::new(Mutex::new(initial_model)),
skip_continue_once: Arc::new(AtomicBool::new(false)),
} }
} }
/// Arm the one-shot: the next claude invocation will run without
/// `--continue`, dropping any prior session context. Idempotent
/// — calling twice in a row before the next turn still consumes
/// to a single fresh-start.
pub fn request_new_session(&self) {
self.skip_continue_once.store(true, Ordering::SeqCst);
}
/// Take + clear the one-shot. Returns true iff the caller should
/// run claude without `--continue` for this turn.
pub fn take_skip_continue(&self) -> bool {
self.skip_continue_once.swap(false, Ordering::SeqCst)
}
/// Currently-selected claude model name. Read on every turn so a /// Currently-selected claude model name. Read on every turn so a
/// `/model <name>` flip takes effect on the next turn. /// `/model <name>` flip takes effect on the next turn.
#[must_use] #[must_use]

View file

@ -228,6 +228,18 @@ async fn run_claude(
mode: ClaudeMode, mode: ClaudeMode,
) -> Result<bool> { ) -> Result<bool> {
let model = bus.model(); let model = bus.model();
// /compact must always run against the existing session — otherwise
// there's nothing to compact. Only normal turns honor the
// operator's "new session" one-shot flag.
let resume = match mode {
ClaudeMode::Turn => !bus.take_skip_continue(),
ClaudeMode::Compact => true,
};
if !resume {
bus.emit(LiveEvent::Note(
"fresh session (--continue suppressed for this turn)".into(),
));
}
let mut cmd = Command::new("claude"); let mut cmd = Command::new("claude");
cmd.arg("--print") cmd.arg("--print")
.arg("--verbose") .arg("--verbose")
@ -235,9 +247,11 @@ async fn run_claude(
.arg("stream-json") .arg("stream-json")
.arg("--model") .arg("--model")
.arg(&model) .arg(&model)
.arg("--continue")
.arg("--settings") .arg("--settings")
.arg(settings); .arg(settings);
if resume {
cmd.arg("--continue");
}
if let Some(p) = system_prompt { if let Some(p) = system_prompt {
cmd.arg("--system-prompt-file").arg(p); cmd.arg("--system-prompt-file").arg(p);
} }

View file

@ -82,6 +82,7 @@ pub async fn serve(
.route("/api/cancel", post(post_cancel_turn)) .route("/api/cancel", post(post_cancel_turn))
.route("/api/compact", post(post_compact)) .route("/api/compact", post(post_compact))
.route("/api/model", post(post_set_model)) .route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.with_state(state); .with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?; let listener = bind_with_retry(addr, "web UI").await?;
@ -427,6 +428,21 @@ async fn post_compact(State(state): State<AppState>) -> Response {
/// final result row. Emits a Note so the operator sees the cancel /// final result row. Emits a Note so the operator sees the cancel
/// landed; the actual state transition back to `idle` happens when /// landed; the actual state transition back to `idle` happens when
/// `run_claude` wakes up and the harness emits `TurnEnd`. /// `run_claude` wakes up and the harness emits `TurnEnd`.
/// Arm a one-shot: the next claude turn drops `--continue`, starting a
/// fresh session. Subsequent turns resume normal `--continue`
/// behavior. Idempotent before the next turn fires — calling twice
/// still results in a single fresh start. Useful when the
/// session-resume context is poisoned (claude went off the rails,
/// hit an unrecoverable refusal, etc.) and a full reset is cheaper
/// than asking claude to forget mid-stream.
async fn post_new_session(State(state): State<AppState>) -> Response {
state.bus.request_new_session();
state.bus.emit(crate::events::LiveEvent::Note(
"operator: new session armed — next turn runs without --continue".into(),
));
Redirect::to("/").into_response()
}
async fn post_cancel_turn(State(state): State<AppState>) -> Response { async fn post_cancel_turn(State(state): State<AppState>) -> Response {
let out = tokio::process::Command::new("pkill") let out = tokio::process::Command::new("pkill")
.args(["-INT", "claude"]) .args(["-INT", "claude"])