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:
parent
14aa7c7acc
commit
034b4fde10
6 changed files with 102 additions and 6 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue