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);
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 {
display: inline-block;
padding: 0.25em 0.8em;

View file

@ -170,11 +170,12 @@
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: '/compact', desc: 'compact the persistent claude session' },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
{ 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' },
{ 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) {
@ -213,6 +214,7 @@
}
const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
const postCompact = () => postSimple('/api/compact', '/compact');
const postNewSession = () => postSimple('/api/new-session', '/new-session');
function handleSlashCommand(line) {
if (!termAPI) return false;
@ -236,6 +238,11 @@
case '/compact':
postCompact();
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': {
const parts = trimmed.split(/\s+/);
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
// 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

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

View file

@ -9,6 +9,7 @@
//! showing "connecting…" until the first event arrives.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, params};
@ -189,6 +190,13 @@ pub struct Bus {
/// Model name passed to `claude --model`. Default `haiku`; the
/// operator can override at runtime via `POST /api/model`.
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 {
@ -213,9 +221,24 @@ impl Bus {
store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
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
/// `/model <name>` flip takes effect on the next turn.
#[must_use]

View file

@ -228,6 +228,18 @@ async fn run_claude(
mode: ClaudeMode,
) -> Result<bool> {
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");
cmd.arg("--print")
.arg("--verbose")
@ -235,9 +247,11 @@ async fn run_claude(
.arg("stream-json")
.arg("--model")
.arg(&model)
.arg("--continue")
.arg("--settings")
.arg(settings);
if resume {
cmd.arg("--continue");
}
if let Some(p) = system_prompt {
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/compact", post(post_compact))
.route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
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
/// landed; the actual state transition back to `idle` happens when
/// `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 {
let out = tokio::process::Command::new("pkill")
.args(["-INT", "claude"])