Skip to main content

agent_engine/session/
actor.rs

1//! `SessionActor` — owns THE `Runtime` + `ConversationState` for one
2//! conversation and runs its turn machine (PLAN-phase2 §2.5).
3//!
4//! Every method body is moved from an existing reactor site (TUI
5//! `dispatch.rs` Abort/Submit/StreamingInput, `stream_handler.rs`
6//! event-queue + stream arms, `tui/mod.rs` teardown, `cmd/chat.rs`
7//! post-turn); the presentation halves stay client-side and are fed by
8//! the envelopes this actor emits. Each `StreamEvent` is forwarded to
9//! clients BEFORE the actor acts on it, so a client sees the same order it
10//! sees today.
11//!
12//! Invariants:
13//! - `Runtime::clone()` resets TTL latches. The actor clones it only for
14//!   driver stream-start (Prepared→Started) and compaction. The stream-start
15//!   clone shares the original's TTL atomics via `share_ttl_latches` so a
16//!   redundant 1h→5m downgrade notice per driver turn is avoided. The
17//!   compaction clone is read-only+discarded and needs no sharing.
18//! - `emit` is the ONLY `seq` increment site.
19//! - `Detach` never touches `stream`/`cancel`; only `End` (and `Cancel`)
20//!   stop a turn.
21
22use std::collections::{HashMap, VecDeque};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use futures::StreamExt;
27use tokio::sync::{broadcast, mpsc, oneshot};
28
29use crate::engine::reactor::{
30    auto_turn_cap_reached, claim_auto_turn_with_cap, drain_event_queue, wake_action_with_cap,
31    EventDisposition, WakeAction,
32};
33use crate::engine::session::ConversationState;
34use crate::engine::setup::BackgroundTasks;
35use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
36use crate::extensions::session_driver::{self as protocol, Grant, Reply};
37use crate::runtime::compaction::{
38    apply_compaction, compact_conversation, preview_compaction_disclosure, CompactionPolicy,
39    CompactionTransition,
40};
41use crate::tools::{SecretPromptHandle, SecretPromptRequest};
42use crate::{
43    AgentEvent, CancellationToken, EngineHost, Result, Runtime, SessionEvent,
44    StreamEvent,
45};
46
47use super::budgets;
48use super::handle::{SessionEndpoints, SessionHandle};
49use super::types::*;
50use super::view::RuntimeView;
51
52/// The in-flight response stream (tui/stream_handler.rs `ActiveStream`).
53pub type ActiveStream = std::pin::Pin<Box<dyn futures::Stream<Item = StreamEvent> + Send>>;
54
55/// `turn_replay` cap (envelopes). §6 #9: the 2 MiB text bound is day 3.
56const TURN_REPLAY_CAP: usize = 4096;
57
58/// The in-flight turn draft (`sessions/<id>.turn`, see
59/// `agent_core::core::session_draft`): what the actor last knew about the
60/// response being streamed, flushed at most once per 1 Hz turn tick.
61#[derive(Default)]
62pub(crate) struct TurnDraftState {
63    /// Session id the open draft is persisted under; `None` = not persisted
64    /// (no turn open, or this actor doesn't write drafts). `base_len` and
65    /// `text` are tracked for every turn: a cancel whose drain times out
66    /// keeps the streamed text (`SessionActor::keep_streamed_partial`).
67    open: Option<String>,
68    /// History length the in-flight response continues from.
69    base_len: usize,
70    /// Text streamed in the in-flight response so far.
71    text: String,
72    /// Tools the in-flight response has called (complete `ToolUse`s), which
73    /// may be running. Not persisted.
74    tools: Vec<String>,
75    /// Changed since the last flush.
76    dirty: bool,
77}
78
79/// What a started turn needs to open its stream (`start_turn`), held while
80/// its request waits for `on_session_start`.
81pub(crate) struct TurnStart {
82    cancel: CancellationToken,
83    steer_rx: mpsc::UnboundedReceiver<String>,
84    auto_approve: bool,
85    completion: crate::runtime::TurnCompletion,
86    /// Events started this turn (nobody typed it): its stream runs under the
87    /// event turn budget (#251). Decided from the turn's trigger at
88    /// `start_turn`, applied when the stream opens (`open_turn_stream`),
89    /// which may be after `on_session_start` has run.
90    event_turn: bool,
91}
92
93/// Why autonomous work may not start now
94/// ([`SessionActor::autonomous_work_blocked`]).
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub(crate) enum WorkBlock {
97    /// A reload checkpointed this session: nothing may start work the reload
98    /// record does not carry until the exec, or until the daemon reports
99    /// that it failed (#255's reload fence).
100    Fenced,
101    /// This session's `on_session_start` has not run yet: a request sent
102    /// now would lack its injection.
103    SessionStartPending,
104}
105
106/// A source of autonomous work in the actor's loop: work nobody asked for
107/// with a command ([`SessionActor::holds`]).
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub(crate) enum WorkSource {
110    /// Queued events (inbox, `synaps send`, worker completions).
111    QueueWake,
112    /// The park grace or a worker re-check.
113    ParkTimer,
114    /// A due prompt-cache keep-warm ping.
115    KeepWarmTimer,
116    /// The armed session driver's 200 ms tick.
117    DriverTick,
118}
119
120/// What `SessionActor::drain_cancelled_stream` recovered from a cancelled
121/// turn's stream.
122#[derive(Default)]
123pub(crate) struct CancelDrain {
124    /// The engine's final history for the turn (last `MessageHistory` seen).
125    pub(crate) history: Option<Vec<crate::SharedMessage>>,
126    /// The stream ended (`Done` / EOF) inside the drain budget.
127    pub(crate) closed: bool,
128}
129
130/// A field that is `None` exactly while the session is `Parked` (B3).
131/// Derefs to the value so the turn machine reads `self.runtime` / `self.conv`
132/// unchanged; every command that needs them goes through `ensure_live()`
133/// first, so the panic path is a programming error, not a runtime state.
134pub(crate) struct Live<T>(Option<T>);
135
136impl<T> Live<T> {
137    pub(crate) fn new(v: T) -> Self {
138        Self(Some(v))
139    }
140    pub(crate) fn is_live(&self) -> bool {
141        self.0.is_some()
142    }
143    pub(crate) fn park_take(&mut self) -> Option<T> {
144        self.0.take()
145    }
146    pub(crate) fn unpark_set(&mut self, v: T) {
147        self.0 = Some(v);
148    }
149}
150
151impl<T> std::ops::Deref for Live<T> {
152    type Target = T;
153    fn deref(&self) -> &T {
154        self.0.as_ref().expect("session is parked: runtime/conv unavailable")
155    }
156}
157
158impl<T> std::ops::DerefMut for Live<T> {
159    fn deref_mut(&mut self) -> &mut T {
160        self.0.as_mut().expect("session is parked: runtime/conv unavailable")
161    }
162}
163
164/// `SYNAPS_DAEMON_PARKED_EVICT_SECS`: how long a PARKED session stays in the
165/// daemon's map before it is evicted (`EndReason::Evicted`). Its state is on
166/// disk; `--continue <id>` rebuilds it exactly as after a daemon restart. The
167/// row only exists so `--attach <id>` / the adopt banner can find it quickly.
168/// Default 1 h; `never` → keep forever.
169pub fn parked_evict_after() -> Option<std::time::Duration> {
170    // Precedence: env > config (`daemon.parked_evict_secs`) > builtin default.
171    let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
172    parked_evict_after_from(
173        std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
174        cfg_secs,
175    )
176}
177
178fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
179    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
180    match v.map(str::trim) {
181        Some("never" | "0" | "off") => None,
182        Some(n) => n
183            .parse::<u64>()
184            .ok()
185            .map(std::time::Duration::from_secs)
186            .or_else(from_cfg),
187        None => from_cfg(),
188    }
189}
190
191/// `SYNAPS_DAEMON_IDLE_END_GRACE_SECS`: how long a ZERO-turn session with no
192/// clients lingers before it ends (F18). Default 5 s — long enough for a
193/// client that disconnected mid-reconnect, short enough that open-and-close
194/// does not pin a Runtime for a minute.
195pub fn idle_end_grace() -> std::time::Duration {
196    std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
197        .ok()
198        .and_then(|v| v.trim().parse::<u64>().ok())
199        .map(std::time::Duration::from_secs)
200        .unwrap_or(std::time::Duration::from_secs(5))
201}
202
203/// `SYNAPS_DAEMON_PARK_GRACE_SECS`: `never` → `None` (Parked disabled);
204/// `n` → n seconds after the last detach once idle; default 60.
205pub fn park_grace() -> Option<std::time::Duration> {
206    match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
207        Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
208        Ok(v) => v
209            .trim()
210            .parse::<u64>()
211            .ok()
212            .map(std::time::Duration::from_secs)
213            .or(Some(DEFAULT_PARK_GRACE)),
214        Err(_) => Some(DEFAULT_PARK_GRACE),
215    }
216}
217
218pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
219
220/// How often a detached idle session re-checks a park its workers hold
221/// (`can_park_ignoring_keepwarm`).
222pub const WORKER_PARK_RECHECK: std::time::Duration = std::time::Duration::from_secs(30);
223
224#[cfg(any(test, feature = "testing"))]
225static WORKER_PARK_RECHECK_OVERRIDE_MS: std::sync::atomic::AtomicU64 =
226    std::sync::atomic::AtomicU64::new(0);
227
228/// [`WORKER_PARK_RECHECK`], or the test override. Code-only, like
229/// `cache_keepwarm::interval_override`: no binary input can reach it.
230fn worker_park_recheck() -> std::time::Duration {
231    #[cfg(any(test, feature = "testing"))]
232    {
233        let ms = WORKER_PARK_RECHECK_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
234        if ms > 0 {
235            return std::time::Duration::from_millis(ms);
236        }
237    }
238    WORKER_PARK_RECHECK
239}
240
241/// Test-only controls (`cfg(test)` / `testing` feature).
242#[cfg(any(test, feature = "testing"))]
243pub mod testing {
244    use std::sync::atomic::Ordering;
245    use std::time::Duration;
246
247    /// Replaces the worker park re-check interval for every session in
248    /// this process while alive (tests that use it must be `#[serial]`).
249    #[must_use = "the override is cleared when the guard drops"]
250    pub struct WorkerParkRecheck(());
251
252    impl WorkerParkRecheck {
253        pub fn set(interval: Duration) -> Self {
254            let ms = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX).max(1);
255            super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(ms, Ordering::Relaxed);
256            Self(())
257        }
258    }
259
260    impl Drop for WorkerParkRecheck {
261        fn drop(&mut self) {
262            super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(0, Ordering::Relaxed);
263        }
264    }
265}
266
267/// `SYNAPS_DAEMON_PROMPT_ABANDON_SECS`: how long a pending host confirmation on
268/// a NON-driver session survives after the LAST client detaches before it is
269/// fail-closed answered `None` (deny) so `can_park()` can proceed. This bounds
270/// the resource pin from an ABANDONED prompt WITHOUT punishing a transient
271/// disconnect (SSH drop, sleep, wifi): a re-attach before expiry cancels it and
272/// the user answers the prompt normally. Default 1 h — generous enough that a
273/// human coming back from lunch still answers; `never`/`0`/`off` → `None` =
274/// disabled = pure pre-#112 behaviour (a prompt survives detach forever).
275///
276/// (The driver-armed case fail-closes immediately on last detach; this deadline
277/// is only for a plain interactive session — see `detach` / `rearm_prompt_abandon`.)
278pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
279    // Precedence: env > config (`daemon.prompt_abandon_secs`) > builtin default.
280    let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
281    prompt_abandon_timeout_from(
282        std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
283        cfg_secs,
284    )
285}
286
287fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
288    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
289    match v.map(str::trim) {
290        Some("never" | "0" | "off") => None,
291        Some(n) => n
292            .parse::<u64>()
293            .ok()
294            .map(std::time::Duration::from_secs)
295            .or_else(from_cfg),
296        None => from_cfg(),
297    }
298}
299
300/// Non-persisted runtime knobs replayed after unpark (last-wins per
301/// variant). Model/reasoning live in the journal already; `/system` is
302/// runtime-only (never journaled) so it replays too (M7).
303fn replayable(setting: &SessionSetting) -> bool {
304    matches!(
305        setting,
306        SessionSetting::SystemPrompt { .. }
307            | SessionSetting::ContextWindow { .. }
308            | SessionSetting::CompactionModel { .. }
309            | SessionSetting::ApiRetries { .. }
310            | SessionSetting::SubagentTimeout { .. }
311            | SessionSetting::MaxToolOutput { .. }
312            | SessionSetting::BashTimeout { .. }
313            | SessionSetting::BashMaxTimeout { .. }
314            | SessionSetting::GrantWorkerModel { .. }
315    )
316}
317
318fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
319    match setting {
320        SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
321        SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
322        SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
323        SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
324        SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
325        SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
326        SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
327        SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
328        SessionSetting::GrantWorkerModel { model } => {
329            let _ = rt.grant_worker_model(model);
330        }
331        _ => {}
332    }
333}
334
335/// One attached client: its meta + the mode it attached with (B1 ownership
336/// needs the mode to pick the next owner when the owner detaches).
337pub(crate) struct AttachedClient {
338    pub(crate) meta: ClientMeta,
339    pub(crate) mode: AttachMode,
340    /// Resolved at attach ([`resolve_client_name`]); unique in the session.
341    pub(crate) name: String,
342    /// Unix ms.
343    pub(crate) attached_at_ms: u64,
344    /// `SessionActor::input_seq` at this client's last input; 0 = none yet.
345    pub(crate) last_input: u64,
346}
347
348/// B2: a spawned `compact_conversation` (§2.5).
349pub(crate) struct CompactionJob {
350    pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
351    pub(crate) source: String,
352    #[allow(dead_code)]
353    pub(crate) started: tokio::time::Instant,
354}
355
356/// Commands that only the input owner may send (B1). Everything else
357/// (`Answer`, `Query`, `Save`, `Detach`, `Attach`, `Resync`, `HostEvent`)
358/// is open to every attached client — except that `answer` refuses an
359/// observer's answer to a Confirm prompt and accepts "Allow all" only from
360/// the owner (#148 C2). `Checkpoint` cancels the owner's turn
361/// and kills its PTYs, so it is owner-only from a client (M1); the daemon
362/// sends it `from: None`.
363fn is_input_command(cmd: &SessionCommand) -> bool {
364    matches!(
365        cmd,
366        SessionCommand::Checkpoint { .. }
367            | SessionCommand::Submit { .. }
368            | SessionCommand::SubmitPrepared { .. }
369            | SessionCommand::Steer { .. }
370            | SessionCommand::SubagentSteer { .. }
371            | SessionCommand::Cancel
372            | SessionCommand::Set { .. }
373            | SessionCommand::Compact { .. }
374            | SessionCommand::NewSession
375            | SessionCommand::EngineCommand { .. }
376            | SessionCommand::PluginCommand { .. }
377            | SessionCommand::Resume { .. }
378            | SessionCommand::KeepWarm { .. }
379            | SessionCommand::DriverStart { .. }
380            | SessionCommand::End {
381                reason: EndReason::ClientQuit
382            }
383    )
384}
385
386/// The MAIN-conversation note for a user steer of a worker (`SubagentSteer`):
387/// a low-severity event, formatted for the model like any drained event.
388fn subagent_steer_event(handle_id: &str, agent_name: &str, text: &str) -> crate::events::types::Event {
389    use crate::events::types::{Event, EventContent, EventSource, Severity};
390    Event {
391        id: uuid::Uuid::new_v4().to_string(),
392        timestamp: chrono::Utc::now(),
393        source: EventSource {
394            source_type: "user".to_string(),
395            name: "user".to_string(),
396            callback: None,
397        },
398        channel: None,
399        sender: None,
400        content: EventContent {
401            text: format!("User steered subagent '{agent_name}' ({handle_id}) directly: {text}"),
402            content_type: "subagent_steer".to_string(),
403            severity: Some(Severity::Low),
404            data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
405            disclosure: None,
406        },
407        expects_response: false,
408        reply_to: None,
409    }
410}
411
412/// The MAIN-conversation note for a worker the user cancelled with
413/// `/workers cancel`: its completion wake is withheld, so without this the
414/// main agent would wait for it (or dispatch the work again).
415pub(crate) fn subagent_user_cancel_event(
416    handle_id: &str,
417    agent_name: &str,
418) -> crate::events::types::Event {
419    use crate::events::types::{Event, EventContent, EventSource, Severity};
420    Event {
421        id: uuid::Uuid::new_v4().to_string(),
422        timestamp: chrono::Utc::now(),
423        source: EventSource {
424            source_type: "user".to_string(),
425            name: "user".to_string(),
426            callback: None,
427        },
428        channel: None,
429        sender: None,
430        content: EventContent {
431            text: format!(
432                "User cancelled subagent '{agent_name}' ({handle_id}) with /workers cancel. It stops at \
433                 its next step and sends no completion wake; do not wait for it. Its partial output is \
434                 readable with subagent_collect (handle_id \"{handle_id}\") only while this session \
435                 stays loaded: the handle does not survive the session being parked."
436            ),
437            content_type: "subagent_cancelled".to_string(),
438            severity: Some(Severity::Low),
439            data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
440            disclosure: None,
441        },
442        expects_response: false,
443        reply_to: None,
444    }
445}
446
447/// The MAIN-conversation note for a worker `/close force` cancelled. The
448/// session ends right after, so this is for whoever continues it later
449/// (`--continue`): the worker never finished and no wake is coming.
450pub(crate) fn subagent_close_cancel_event(
451    handle_id: &str,
452    agent_name: &str,
453) -> crate::events::types::Event {
454    let mut event = subagent_user_cancel_event(handle_id, agent_name);
455    event.content.text = format!(
456        "User closed this session with /close force while subagent '{agent_name}' ({handle_id}) was \
457         running, so it was cancelled before it finished. It sends no completion wake; do not wait for \
458         it. If its work is still needed, check what it left behind and dispatch it again."
459    );
460    event
461}
462
463/// `KeepWarm{on: true}`'s notice (`/pin on`, `daemon pin`, `--keep-warm`).
464pub const PIN_ON_NOTICE: &str = "pinned: this session stays loaded (never parked or idle-ended, \
465     and the daemon will not idle-exit while it is pinned) and comes back after daemon restarts. \
466     /pin off to undo.";
467/// `KeepWarm{on: false}`'s notice.
468pub const PIN_OFF_NOTICE: &str = "unpinned: this session parks again when idle and won't come back after a restart";
469
470/// The engine command behind `/close` (`SessionActor::close_command`).
471pub(crate) const CLOSE_COMMAND: &str = "close";
472
473fn is_close_command(cmd: &SessionCommand) -> bool {
474    matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND)
475}
476
477fn command_name(cmd: &SessionCommand) -> &'static str {
478    match cmd {
479        SessionCommand::Submit { .. } => "submit",
480        SessionCommand::SubmitPrepared { .. } => "submit_prepared",
481        SessionCommand::Steer { .. } => "steer",
482        SessionCommand::SubagentSteer { .. } => "subagent_steer",
483        SessionCommand::Cancel => "cancel",
484        SessionCommand::Answer { .. } => "answer",
485        SessionCommand::Set { .. } => "set",
486        SessionCommand::Compact { .. } => "compact",
487        SessionCommand::NewSession => "new_session",
488        SessionCommand::Save => "save",
489        SessionCommand::Query { .. } => "query",
490        SessionCommand::Attach { .. } => "attach",
491        SessionCommand::Detach { .. } => "detach",
492        SessionCommand::End { .. } => "end",
493        SessionCommand::Resync { .. } => "resync",
494        SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND => "close",
495        SessionCommand::EngineCommand { .. } => "engine_command",
496        SessionCommand::PluginCommand { .. } => "plugin_command",
497        SessionCommand::Resume { .. } => "resume",
498        SessionCommand::Checkpoint { .. } => "checkpoint",
499        SessionCommand::KeepWarm { .. } => "keep_warm",
500        SessionCommand::TakeInput => "take_input",
501        SessionCommand::Park => "park",
502        SessionCommand::AdoptWorkers { .. } => "adopt_workers",
503        SessionCommand::RestoreToolActivations { .. } => "restore_tool_activations",
504        SessionCommand::ReloadAborted => "reload_aborted",
505        SessionCommand::ArmPark => "arm_park",
506        SessionCommand::HostEvent(_) => "host_event",
507        SessionCommand::DriverStart { .. } => "driver_start",
508    }
509}
510
511pub struct SessionActor {
512    pub(crate) id: SessionId,
513    pub(crate) meta: SessionMeta,
514    pub(crate) config: SessionConfig,
515    /// THE runtime; `session_id` + `cwd` set by `create`. `None` ⇔ Parked.
516    pub(crate) runtime: Live<Runtime>,
517    /// `None` ⇔ Parked.
518    pub(crate) conv: Live<ConversationState>,
519    /// B3: unpark needs a fresh `foreground_runtime()`.
520    pub(crate) host: Arc<EngineHost>,
521    /// Kept across Park (`finish()` needs it without a Runtime).
522    pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
523    /// Session-lifetime queue; `background` pushes into it, every Runtime
524    /// this actor builds is pointed at it (`Runtime::set_event_queue`).
525    pub(crate) event_queue: Arc<crate::events::EventQueue>,
526    /// Last-wins per variant; replayed after unpark (non-persisted knobs).
527    pub(crate) settings_replay: Vec<SessionSetting>,
528    pub(crate) keep_warm: bool,
529    /// Opt-in prompt-cache keep-warm pings while idle (task #444). Unrelated
530    /// to `keep_warm` above, which only stops the session from parking.
531    pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
532    /// Armed on last-detach-while-idle / idle-while-detached.
533    pub(crate) park_deadline: Option<tokio::time::Instant>,
534    /// `park_deadline` when it was armed as a worker re-check (workers held
535    /// the park), not as the reconnect grace: a re-check that finds the
536    /// session parkable arms the grace instead of parking.
537    pub(crate) park_recheck_at: Option<tokio::time::Instant>,
538    /// (P11) Armed when the last client detaches while a pending host prompt is
539    /// held on a NON-driver session; a re-attach cancels it, expiry denies the
540    /// prompt(s) so `can_park()` can proceed. `None` = no abandoned prompt (or
541    /// the deadline is disabled by config).
542    pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
543    // ── turn machine: the run() loop locals + App fields ──
544    pub(crate) stream: Option<ActiveStream>,
545    pub(crate) cancel: Option<CancellationToken>,
546    pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
547    /// An event-started turn's way onto the session budget: `steer` sends
548    /// the owner's message through it, and the turn's stream switches to the
549    /// session budget when it delivers that message. `None` for every other
550    /// turn (they already run on the session budget).
551    pub(crate) owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
552    pub(crate) streaming: bool,
553    pub(crate) turn_baseline: usize,
554    pub(crate) consecutive_auto_turns: u32,
555    /// Formatted events `on_queue_wake` steered into this turn's stream
556    /// that the engine has not acknowledged (`SteeringDelivered`) yet. On
557    /// cancel they would die with the steering channel; `cancel_turn` moves
558    /// them into history after the interruption marker instead.
559    pub(crate) turn_steered_events: Vec<String>,
560    /// Set by the engine when the running turn reaches its normal end
561    /// (`TurnCompletion`); read when a cancel races that end.
562    pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
563    /// A turn cut from outside `cancel_turn` (driver revocation cancels the
564    /// driver turn's token): the stream is given until this instant to
565    /// deliver its terminal event, then dropped and the turn finished as
566    /// interrupted (`finish_revoked_turn`).
567    pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
568    /// In-flight turn draft for crash recovery (persisting sessions only).
569    pub(crate) turn_draft: TurnDraftState,
570    /// Session saves and turn-draft writes, off the turn machine
571    /// (`session::persister`).
572    pub(crate) persister: super::persister::Persister,
573    /// The snapshot last handed to the persister: an identical one is not
574    /// queued again (a turn's end asks several times for the same state).
575    pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
576    // ── prompts ──
577    pub(crate) secret_prompt_handle: SecretPromptHandle,
578    pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
579    /// Held across detach; replayed in `AttachSnapshot.pending_prompts`.
580    pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
581    pub(crate) next_prompt_id: u64,
582    // ── clients ──
583    pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
584    pub(crate) events: broadcast::Sender<Envelope>,
585    pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
586    pub(crate) attached: HashMap<ClientId, AttachedClient>,
587    /// B1: the one client whose input commands are honoured (`None` = any
588    /// host-originated sender only). With `client_input = shared` it is the
589    /// *active* client: the drive client that sent input last.
590    pub(crate) input_owner: Option<ClientId>,
591    /// Who may drive (config `client_input`, or the create override).
592    pub(crate) client_input: ClientInput,
593    /// Bumped on each input from a client ([`AttachedClient::last_input`]).
594    pub(crate) input_seq: u64,
595    pub(crate) next_client_id: u64,
596    pub(crate) seq: u64,
597    pub(crate) turn_replay: VecDeque<Envelope>,
598    /// Last Codex hosted-web_search state line shown (once per session per
599    /// model/state; see `codex_wire::session_web_search_notice`).
600    pub(crate) web_search_notice: Option<String>,
601    pub(crate) state: AttachState,
602    pub(crate) background: BackgroundTasks,
603    /// B2: spawned compaction in flight (`busy` while `Some`).
604    pub(crate) compact: Option<CompactionJob>,
605    /// `compact_inline` is running (it blocks the actor; listings still
606    /// see the session busy).
607    pub(crate) compacting_inline: bool,
608    /// `on_session_start` for this session. `await_extensions=false`: a
609    /// task waits for process-level discovery and its turn at the host's
610    /// gate (one hook at a time), then calls it, so boot never blocks on
611    /// it. A turn that comes first moves it ahead of background hooks and
612    /// waits for it without blocking the loop (`turn_awaiting_start`); a
613    /// keep-warm ping waits with its timer off (`holds`).
614    pub(crate) session_start: super::session_start::SessionStart,
615    /// A started turn whose request waits for `on_session_start`: its
616    /// stream opens when the hook has run. The turn is live meanwhile
617    /// (`streaming`), so Cancel, End and a driver revocation end it as any
618    /// turn, with nothing sent.
619    pub(crate) turn_awaiting_start: Option<TurnStart>,
620    /// 1 Hz `SubagentRows` cadence while a turn runs (`None` when idle).
621    pub(crate) subagent_tick: Option<tokio::time::Interval>,
622    /// Worker-registry generation of the current/last turn: a user
623    /// interrupt cancels only the workers this turn launched.
624    pub(crate) turn_worker_generation: u64,
625    /// Mirrors `handle.lifecycle()` (B3 writes Parking/Parked).
626    pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
627    /// Mirrors `handle.journal_id()` (B2 stores the successor id).
628    pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
629    /// Mirrors `handle.presence()` (clients / owner / pending prompts).
630    pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
631    /// Mirrors `handle.name()`; `sync_name` after any rename.
632    pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
633    /// Mirrors `handle.pinned()`; written with `keep_warm` (create seeds it
634    /// from `meta.pinned`, `KeepWarm{on}` stores it).
635    pub(crate) keep_warm_cell: Arc<std::sync::atomic::AtomicBool>,
636    /// F10: per-session journal ownership lock. Held while Live, released on Park.
637    pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
638    // ── E: session driver (actor-side, behind `session.drive` permission) ──
639    pub(crate) driver: Option<super::driver::DriverState>,
640    pub(crate) driver_pending: Option<super::driver::DriverPending>,
641    /// Generation counter for invalidating stale pending results.
642    pub(crate) driver_generation: u64,
643    /// Interrupted owner (for generic stop commands across revocation).
644    pub(crate) driver_interrupted_owner: Option<String>,
645    /// 200 ms cadence for `driver_tick()`, always present.
646    pub(crate) driver_tick_interval: tokio::time::Interval,
647    /// Set by a reload checkpoint until the `execv` (or until the daemon
648    /// reports it failed): see [`ReloadFence`].
649    pub(crate) reload_fence: Option<ReloadFence>,
650    /// Shared with the handle: `config.memory_mode`, readable by a reload
651    /// that gets no checkpoint reply.
652    pub(crate) memory_mode_slot: super::handle::MemoryModeSlot,
653    /// This runtime's memory has been seen active (a grant took effect).
654    /// Only then does "memory is off" mean consent was revoked; see
655    /// `effective_memory_mode`. Reset whenever the runtime is rebuilt.
656    pub(crate) memory_seen_active: bool,
657    /// While parked: the exact tool activations the dropped runtime held,
658    /// handed to the runtime the unpark builds (and to a reload record
659    /// written while parked). Empty while live.
660    pub(crate) parked_tool_activations: Vec<crate::tools::activation::CarriedActivation>,
661}
662
663/// A reload checkpoint wrote this session's reload record; until the
664/// `execv` replaces the process nothing may start new work the record does
665/// not know about. Queue wakes stay queued (the record carries the wakes it
666/// owes), and keep-warm pings, driver ticks and parking wait. Lifted by
667/// `ReloadAborted` (the exec failed) or, as a backstop, at `deadline`.
668pub(crate) struct ReloadFence {
669    pub(crate) deadline: tokio::time::Instant,
670    /// What the checkpoint recorded: re-announced to this image when the
671    /// exec does not happen.
672    pub(crate) workers: Vec<crate::runtime::subagent::CarriedWorker>,
673}
674
675/// A create that fails after taking the journal lock: a FRESH session never
676/// journaled, so its `.lock` would be litter next to no `.json` (F18). A
677/// continued session's lock file sits next to its journal and stays.
678fn release_fresh_lock(lock: Option<agent_core::session_lock::SessionLock>, continued: bool) {
679    if let (Some(lock), false) = (lock, continued) {
680        lock.release_and_remove();
681    }
682}
683
684impl SessionActor {
685    /// = today's `setup::boot()` per session (foreground_runtime →
686    /// resolve_session_and_prompt → journal lock → set_cwd → model override →
687    /// spawn_session_background → finish_session_setup) then
688    /// `on_session_start` (keyed injection).
689    pub(crate) async fn create(
690        host: &Arc<EngineHost>,
691        mut cfg: SessionConfig,
692    ) -> Result<(SessionHandle, SessionTask)> {
693        // cwd goes in BEFORE apply_config (inside foreground_runtime_for) so the
694        // one-shot memory binding scopes to the session's project (§4).
695        // A session that made its own `/memory` choice (carried by a reload)
696        // gets that, not the operator default.
697        let mut runtime = host
698            .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
699            .await?;
700        if let Some(mode) = cfg.memory_mode {
701            runtime.restore_session_memory_mode(mode);
702        }
703        let config: crate::SynapsConfig = (**host.config()).clone();
704        let client_input = cfg.client_input.unwrap_or(config.client_input);
705
706        let mut sb = crate::engine::setup::resolve_session_and_prompt(
707            &mut runtime,
708            &cfg.continue_session,
709            cfg.system.as_deref(),
710            cfg.prompt_manifest.as_deref(),
711        )?;
712        // F10: acquire the per-session journal ownership lock BEFORE anything
713        // publishes or writes for this id: the `--name` save, the registry
714        // card + per-session socket (`spawn_session_background`), the START
715        // index record and `on_session_start`. A refused `--continue` of a
716        // session live in another process used to publish its own card over
717        // the owner's first, then delete it on drop: the owner stayed live
718        // (socket up, journal locked) but `synaps send` and peers lost it.
719        let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
720        let session_lock = if sb.continued {
721            // Continuing an existing session — the lock MUST succeed.
722            // If another process holds it, refuse with an actionable error.
723            let dir = agent_core::session_lock::sessions_dir();
724            let holder = agent_core::session_lock::LockHolder {
725                pid: std::process::id(),
726                kind: lock_kind.to_string(),
727            };
728            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
729                Ok(lock) => Some(lock),
730                Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
731                    let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
732                    return Err(crate::RuntimeError::Session(msg.to_string()));
733                }
734                Err(e) => {
735                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
736                    None
737                }
738            }
739        } else {
740            // Fresh session — best-effort lock.
741            let dir = agent_core::session_lock::sessions_dir();
742            let holder = agent_core::session_lock::LockHolder {
743                pid: std::process::id(),
744                kind: lock_kind.to_string(),
745            };
746            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
747                Ok(lock) => Some(lock),
748                Err(e) => {
749                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
750                    None
751                }
752            }
753        };
754
755        // `--name` at create: apply BEFORE the registry entry is written so
756        // `synaps send --session <name>` and `--continue <name>` resolve
757        // from the first second — not only after a later `--continue`.
758        if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
759            if let Err(e) = sb.session.set_name(name) {
760                release_fresh_lock(session_lock, sb.continued);
761                return Err(crate::RuntimeError::Session(format!("--name {name:?}: {e}")));
762            }
763            if cfg.persist {
764                // Persist the name now so disk resolution (chain/name → id)
765                // agrees with the live map even before the first turn.
766                if let Err(e) = sb.session.save().await {
767                    tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
768                }
769            }
770        }
771        runtime.set_cwd(cfg.cwd.clone());
772        // T5: rehydrate env from journal when the client sent none.
773        // Precedence: explicit Hello.env from the continuing client > journal > None.
774        // Never fall back to the daemon's own process env.
775        if cfg.env.is_none() {
776            if let Some(ref journal_env) = sb.session.env {
777                cfg.env = Some(journal_env.clone());
778            }
779            if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
780                cfg.env_stripped = sb.session.env_stripped.clone();
781            }
782        }
783        // Always persist the latest env on the session so the next
784        // restart/continue sees it — a fresh client env supersedes
785        // whatever was journaled previously.
786        if cfg.env.is_some() {
787            sb.session.env = cfg.env.clone();
788            sb.session.env_stripped = cfg.env_stripped.clone();
789        }
790        runtime.set_env(cfg.env.clone());
791        runtime.set_env_stripped(cfg.env_stripped.clone());
792        // CLI `--model` overrides whatever was persisted (rpc.rs precedent).
793        if let Some(ref m) = cfg.model_override {
794            runtime.set_model(m.clone());
795        }
796
797        // Shared-inbox files are for whoever is at a keyboard: the gate stays
798        // shut until a client owns input (`publish_presence`), so a detached,
799        // parked or freshly rehydrated session is never woken by them.
800        let background = match crate::engine::setup::spawn_session_background_gated(
801            &runtime,
802            &sb.session,
803            Arc::new(std::sync::atomic::AtomicBool::new(false)),
804        ) {
805            Ok(background) => background,
806            Err(e) => {
807                release_fresh_lock(session_lock, sb.continued);
808                return Err(e);
809            }
810        };
811        crate::engine::setup::finish_session_setup(
812            &mut runtime,
813            &config,
814            &sb.session,
815            cfg.cwd.clone(),
816            crate::engine::setup::IndexRecord::Start,
817        );
818
819        // C2: per-session on_session_start (keyed injection) once the
820        // process-level discovery is known-finished. Never re-runs discovery.
821        // Every call goes through the host's session-start gate.
822        // `await_extensions=false` (TUI, a reload's restore): a spawned task
823        // runs it (`session_start` below), so boot never blocks on it.
824        // `startup.extensions_ready_timeout_secs` (config, default 30) bounds
825        // the wait on extension discovery only; the const remains the
826        // fallback. `run_at_create` then waits its turn at the gate, behind a
827        // running hook and older foreground ones (see `session_start`).
828        let ext_ready_timeout = extensions_ready_timeout(&config);
829        if cfg.await_extensions {
830            if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
831                .await
832                .is_err()
833            {
834                tracing::warn!(
835                    budget_secs = ext_ready_timeout.as_secs(),
836                    "extensions_ready timed out — on_session_start may miss late extensions"
837                );
838            }
839            super::session_start::run_at_create(host, runtime.hook_bus(), &sb.session.id).await;
840        }
841
842        let id = SessionId::from(sb.session.id.clone());
843
844        let meta = SessionMeta {
845            id: id.clone(),
846            name: sb.session.name.clone(),
847            model: runtime.model().to_string(),
848            cwd: cfg.cwd.clone(),
849            created_at: sb.session.created_at,
850            continued: sb.continued,
851            continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
852            host_pid: std::process::id(),
853            lifecycle: SessionLifecycle::Live,
854            clients: 0,
855            input_owner: None,
856            awaiting_input: 0,
857            roster: Vec::new(),
858            client_input: Some(client_input),
859            journal_id: sb.session.id.clone(),
860            locked_by: None,
861            pinned: cfg.keep_warm,
862            busy: false,
863            state_since: None,
864            park_due: None,
865            evict_due: None,
866            title: None,
867            cost_usd: 0.0,
868            messages: 0,
869        };
870        let mut conv = if sb.continued {
871            ConversationState::from_resumed(sb.session)
872        } else {
873            ConversationState::new(sb.session)
874        };
875        conv.api_messages = sb.api_messages;
876        conv.total_input_tokens = sb.total_input_tokens;
877        conv.total_output_tokens = sb.total_output_tokens;
878        conv.session_cost = sb.session_cost;
879        // Crash recovery (the last process died mid-turn): only the lock
880        // holder may fold the turn draft in, persist it and remove it.
881        if sb.continued && cfg.persist && session_lock.is_some() {
882            crate::engine::setup::recover_turn_draft(&mut conv).await;
883        }
884
885        let view = RuntimeView::from_runtime(&runtime).await;
886        let hook_bus = Arc::clone(runtime.hook_bus());
887        let event_queue = Arc::clone(runtime.event_queue());
888        let keep_warm = cfg.keep_warm;
889        let (
890            handle,
891            SessionEndpoints {
892                cmd_rx,
893                events,
894                view,
895                lifecycle,
896                journal_id,
897                presence,
898                name,
899                keep_warm: keep_warm_cell,
900                cache_keepwarm,
901                memory_mode,
902            },
903        ) = SessionHandle::new(meta.clone(), view);
904        // The mode is fixed for the session: seed the listing with it now.
905        // Otherwise only a presence publish (attach, detach, input) carries
906        // it, so a session restored at reload, with no client back yet,
907        // listed as `owner=-`. Not `publish_presence()`: that also sets the
908        // inbox gate, which stays as `SessionHandle::new` left it.
909        presence.store(Arc::new(super::handle::Presence {
910            client_input: Some(client_input),
911            ..Default::default()
912        }));
913        *memory_mode.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = cfg.memory_mode;
914        let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
915        let session_start = if cfg.await_extensions {
916            super::session_start::SessionStart::Done
917        } else if cfg.restore_parked {
918            // Parked right after create: the hook waits for the unpark.
919            super::session_start::SessionStart::Pending
920        } else {
921            super::session_start::SessionStart::queue(
922                host,
923                Arc::clone(&hook_bus),
924                Arc::clone(&journal_id),
925                ext_ready_timeout,
926            )
927        };
928
929        let mut actor = SessionActor {
930            id,
931            meta,
932            config: cfg,
933            runtime: Live::new(runtime),
934            conv: Live::new(conv),
935            host: Arc::clone(host),
936            hook_bus,
937            event_queue,
938            settings_replay: Vec::new(),
939            keep_warm,
940            cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
941                config.cache_keepwarm,
942                config.cache_keepwarm_auto_max_hours,
943                cache_keepwarm,
944            ),
945            park_deadline: None,
946            park_recheck_at: None,
947            prompt_abandon_deadline: None,
948            stream: None,
949            cancel: None,
950            steer_tx: None,
951            owner_steer_budget: None,
952            streaming: false,
953            turn_baseline: 0,
954            consecutive_auto_turns: 0,
955            turn_steered_events: Vec::new(),
956            turn_completion: None,
957            revoked_turn_deadline: None,
958            turn_draft: TurnDraftState::default(),
959            persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
960            last_queued_save: None,
961            secret_prompt_handle: SecretPromptHandle::new(sp_tx),
962            secret_prompt_rx,
963            pending_prompts: VecDeque::new(),
964            next_prompt_id: 1,
965            cmd_rx,
966            events,
967            view,
968            attached: HashMap::new(),
969            input_owner: None,
970            client_input,
971            input_seq: 0,
972            next_client_id: 1,
973            seq: 0,
974            turn_replay: VecDeque::new(),
975            web_search_notice: None,
976            state: AttachState::Detached { running: false },
977            background,
978            compact: None,
979            compacting_inline: false,
980            session_start,
981            turn_awaiting_start: None,
982            subagent_tick: None,
983            turn_worker_generation: 0,
984            lifecycle,
985            journal_id,
986            presence,
987            name,
988            keep_warm_cell,
989            session_lock,
990            driver: None,
991            driver_pending: None,
992            driver_generation: 0,
993            driver_interrupted_owner: None,
994            driver_tick_interval: {
995                let mut interval = tokio::time::interval(Duration::from_millis(200));
996                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
997                interval
998            },
999            reload_fence: None,
1000            memory_mode_slot: memory_mode,
1001            memory_seen_active: false,
1002            parked_tool_activations: Vec::new(),
1003        };
1004        // Whether the build's grant (default or restored choice) took.
1005        actor.observe_memory();
1006        if actor.keep_warm {
1007            actor.note_pin();
1008        }
1009        Ok((handle, SessionTask(actor)))
1010    }
1011
1012    /// Publish `conv.session.name` to the handle (listings, `--continue
1013    /// <name>`) and rewrite this session's registry entry so `synaps send
1014    /// --session <name>` resolves the new name. Call after any rename.
1015    /// Write the pin as it stands now to the daemon's pin book (no-op
1016    /// in-process): the entry is added while pinned, dropped otherwise.
1017    pub(crate) fn note_pin(&self) {
1018        let Some(sink) = self.host.pin_sink() else { return };
1019        let journal = (**self.journal_id.load()).clone();
1020        if self.keep_warm {
1021            let kw = self.cache_keepwarm.override_mode.map(|m| m.to_string());
1022            sink.pinned(self.id.as_str(), &journal, self.conv.session.name.as_deref(), self.config.cwd.as_deref(), kw.as_deref());
1023        } else {
1024            sink.unpinned(self.id.as_str(), &journal);
1025        }
1026    }
1027
1028    /// Publish a new journal id; a pinned session's book entry follows it.
1029    pub(crate) fn set_journal_id(&mut self, new_id: String) {
1030        let old = self.journal_id.swap(Arc::new(new_id.clone()));
1031        if self.keep_warm && *old != new_id {
1032            if let Some(sink) = self.host.pin_sink() {
1033                sink.moved(&old, &new_id);
1034            }
1035        }
1036    }
1037
1038    pub(crate) fn sync_name(&mut self) {
1039        let name = self.conv.session.name.clone();
1040        self.meta.name = name.clone();
1041        self.name.store(Arc::new(name.clone()));
1042        if self.keep_warm {
1043            self.note_pin();
1044        }
1045        if let Err(e) = self.background.update_session_name(name.as_deref()) {
1046            tracing::warn!(session = %self.id, "registry name update failed: {e}");
1047        }
1048    }
1049
1050    // ── emit ─────────────────────────────────────────────────────────────
1051
1052    /// The ONLY seq++ site. Pushes to `turn_replay` while streaming, except
1053    /// prompt traffic (never replayed), per-client replies, and full-history
1054    /// envelopes (`MessageHistory`, `Conversation`): the engine publishes one
1055    /// per round, and an attaching client already gets the LATEST history in
1056    /// its snapshot's `conversation` — replaying older ones would ship the
1057    /// whole history once per round and roll its mirror back to a stale
1058    /// state. The ring's `TurnStarted` drops `user_text`: that prompt is
1059    /// already in the snapshot's history (a replayed copy would show it
1060    /// twice). Completed rounds leave the ring when their checkpoint is
1061    /// adopted (`trim_replay_to_checkpoint`).
1062    pub(crate) fn emit(&mut self, event: SessionEventWire) {
1063        let replay = self.streaming
1064            && !matches!(
1065                event,
1066                SessionEventWire::Prompt(_)
1067                    | SessionEventWire::PromptResolved { .. }
1068                    | SessionEventWire::Attached { .. }
1069                    | SessionEventWire::QueryResult { .. }
1070                    | SessionEventWire::Conversation(_)
1071                    | SessionEventWire::Stream(StreamEvent::Session(
1072                        SessionEvent::MessageHistory(_)
1073                    ))
1074            );
1075        let env = Envelope {
1076            session_id: self.id.clone(),
1077            seq: self.seq,
1078            ts: chrono::Utc::now(),
1079            event,
1080        };
1081        self.seq += 1;
1082        if replay {
1083            if self.turn_replay.len() >= TURN_REPLAY_CAP {
1084                self.turn_replay.pop_front();
1085            }
1086            let mut ring = env.clone();
1087            if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
1088                *user_text = None;
1089            }
1090            self.turn_replay.push_back(ring);
1091        }
1092        // No receivers is not an error: streams are not tied to clients.
1093        let _ = self.events.send(env);
1094    }
1095
1096    /// A round checkpoint was adopted: every round in it is now in the
1097    /// attach snapshot's `conversation`, so the replay ring drops those
1098    /// rounds' display events — a mid-turn attach would otherwise render
1099    /// them twice (once from history, once from the ring). The engine
1100    /// publishes the checkpoint at a round boundary, so a tool call and its
1101    /// result are always on the same side of the cut. Kept: the turn's
1102    /// `TurnStarted`, notices, subagent progress, `Usage`.
1103    fn trim_replay_to_checkpoint(&mut self) {
1104        self.turn_replay.retain(|env| {
1105            !matches!(
1106                env.event,
1107                SessionEventWire::Stream(StreamEvent::Llm(_))
1108                    | SessionEventWire::Stream(StreamEvent::Agent(
1109                        AgentEvent::SteeringDelivered { .. }
1110                    ))
1111            )
1112        });
1113    }
1114
1115    pub(crate) fn emit_conversation(&mut self) {
1116        let snap = self.conv.snapshot(self.consecutive_auto_turns);
1117        self.emit(SessionEventWire::Conversation(snap));
1118    }
1119
1120    pub(crate) async fn publish_view(&mut self) {
1121        let v = RuntimeView::from_runtime(&self.runtime).await;
1122        self.view.store(Arc::new(v));
1123    }
1124
1125    /// Queue a save of the current conversation. Never waits: the write
1126    /// runs on the actor's persister (latest wins), so a slow disk cannot
1127    /// stall the turn machine.
1128    pub(crate) fn request_save(&mut self) {
1129        if !self.config.persist || !self.conv.is_live() {
1130            return;
1131        }
1132        let Some(session) = self.conv.prepare_save() else {
1133            return;
1134        };
1135        let key = super::persister::SnapshotKey::of(&session);
1136        // This exact state is already queued or on disk — unless the last
1137        // write failed, in which case it is queued again.
1138        if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
1139            return;
1140        }
1141        self.last_queued_save = Some(key);
1142        self.persister.save(session);
1143    }
1144
1145    /// Wait, at most `SAVE_TIMEOUT`, until every queued save and draft
1146    /// operation has been applied. `false` on a failed save or the timeout
1147    /// (the queued writes still land, in order, when the disk catches up).
1148    pub(crate) async fn flush_saves(&mut self) -> bool {
1149        let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
1150            Ok(ok) => ok,
1151            Err(_) => {
1152                tracing::warn!(session = %self.id, "session save still pending after the save budget");
1153                false
1154            }
1155        };
1156        if !ok {
1157            // Whatever failed is not known to be on disk: queue it again next
1158            // time instead of skipping it as a duplicate.
1159            self.last_queued_save = None;
1160        }
1161        ok
1162    }
1163
1164    /// Save now: queue the current conversation and wait for it (bounded,
1165    /// see `flush_saves`).
1166    pub(crate) async fn save(&mut self) -> bool {
1167        self.request_save();
1168        self.flush_saves().await
1169    }
1170
1171    /// A turn is over: announce `Idle` only once everything it queued is on
1172    /// disk (bounded, `flush_saves`). `Idle` has always meant "the session
1173    /// is saved": a client or script that reads the session, quits or
1174    /// hands off on `Idle` must never see the previous state. Per-round
1175    /// saves stay in the background; this waits once per turn.
1176    pub(crate) async fn announce_idle(&mut self) {
1177        // A turn may have revoked memory consent (the `memory_context`
1178        // tool): keep the handle's slot current for a reload that gets no
1179        // checkpoint reply.
1180        self.sync_memory_choice();
1181        self.flush_saves().await;
1182        // Before `Idle`, so a test that saw `Idle` knows the hook is in line.
1183        #[cfg(any(test, feature = "testing"))]
1184        if super::session_start::testing::take_requeue(&self.journal_id.load()) {
1185            self.queue_session_start();
1186        }
1187        self.emit(SessionEventWire::Idle);
1188        self.cache_keepwarm_on_idle().await;
1189    }
1190
1191    /// Wall 1 — actor-owned context-head checkpoint persistence.
1192    ///
1193    /// The receipt stays in-process (actor task → runtime stream task).
1194    /// It never crosses the wire — `SessionEventWire` maps this to `Done`.
1195    ///
1196    /// Park ordering: `can_park()` requires `!self.streaming`, and streaming
1197    /// is only cleared on stream completion/error. The checkpoint event
1198    /// arrives mid-stream, so park cannot race with a pending checkpoint.
1199    /// No explicit drain is needed.
1200    async fn handle_context_head_checkpoint(
1201        &mut self,
1202        session_id: String,
1203        messages: Vec<crate::SharedMessage>,
1204        receipt: agent_core::core::context_head::ContextHeadReceipt,
1205    ) {
1206        self.turn_draft_rolled_over();
1207        // Stale session id guard (compaction may have changed it).
1208        if session_id != self.conv.session.id {
1209            tracing::warn!(
1210                event_id = %session_id,
1211                actor_id = %self.conv.session.id,
1212                "context head checkpoint: stale session id — ignoring"
1213            );
1214            receipt.complete(Err(std::io::Error::new(
1215                std::io::ErrorKind::InvalidInput,
1216                "stale session id after compaction",
1217            )));
1218            return;
1219        }
1220
1221        // Non-persistent sessions (tests / --no-persist): complete without I/O.
1222        if !self.config.persist {
1223            receipt.complete(Ok(()));
1224            return;
1225        }
1226
1227        // A queued ordinary save must not land after (and over) the durable
1228        // head: drain the persister first.
1229        if !self.persister.flush().await {
1230            tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
1231        }
1232        let result = self.conv.persist_context_head(&session_id, messages).await;
1233        if let Err(ref e) = result {
1234            tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
1235        }
1236        let succeeded = result.is_ok();
1237        receipt.complete(result);
1238
1239        // P5: observe_checkpoint — revoke driver on failure or mismatch.
1240        if let Some(driver) = &self.driver {
1241            if !succeeded
1242                || driver.grant.session_id != session_id
1243                || session_id != self.conv.session.id
1244            {
1245                self.driver_revoke("context checkpoint failed or session replaced");
1246            }
1247        }
1248    }
1249
1250
1251    /// C3: the checkpoint reply payload (`SessionReloadRecord`).
1252    pub(crate) fn reload_record(&self) -> SessionReloadRecord {
1253        let view = self.view.load();
1254        SessionReloadRecord {
1255            // A consent revocation since the last record counts.
1256            config: SessionConfig {
1257                memory_mode: self.effective_memory_mode(),
1258                ..self.config.clone()
1259            },
1260            keep_warm: self.keep_warm,
1261            lifecycle: SessionLifecycle::from_u8(
1262                self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
1263            ),
1264            settings_replay: self.settings_replay.clone(),
1265            cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
1266            model: view.model.clone(),
1267            thinking_level: view.thinking_level.clone(),
1268            workers: Vec::new(),
1269            tool_activations: if self.runtime.is_live() {
1270                self.runtime.carried_tool_activations()
1271            } else {
1272                self.parked_tool_activations.clone()
1273            },
1274        }
1275    }
1276
1277    /// Also the one place the inbox gate follows `input_owner`: every owner
1278    /// change (attach, detach, takeover) publishes presence. `Observe`
1279    /// clients never own input, so a keep-warm observer keeps it shut.
1280    ///
1281    /// Also the `/sessions` state: busy, `state_since` (restamped whenever
1282    /// idle/busy/parked changes), the park / eviction deadline as wall
1283    /// clock, and the conversation's title / cost / message count (kept as
1284    /// last published while Parked). Every transition that moves them —
1285    /// turn and compaction start/end, attach/detach, `rearm_park`,
1286    /// park/unpark — ends in a publish; stream deltas do not.
1287    pub(crate) fn publish_presence(&self) {
1288        use super::handle::{Presence, PresenceState};
1289        self.background
1290            .set_inbox_accepting(self.input_owner.is_some());
1291        let prev = self.presence.load();
1292        let busy = self.busy() || self.compacting_inline;
1293        let parked = self.is_parked();
1294        let state = if parked {
1295            PresenceState::Parked
1296        } else if busy {
1297            PresenceState::Busy
1298        } else {
1299            PresenceState::Idle
1300        };
1301        let now = chrono::Utc::now();
1302        let state_since = match prev.state_since {
1303            Some(t) if prev.state == state => Some(t),
1304            _ => Some(now),
1305        };
1306        let wall = |at: tokio::time::Instant| {
1307            let left = at.saturating_duration_since(tokio::time::Instant::now());
1308            chrono::Duration::from_std(left)
1309                .ok()
1310                .and_then(|d| now.checked_add_signed(d))
1311        };
1312        // A worker re-check is not a park (it only looks again), nor is a
1313        // grace that workers now hold: its firing would only re-check. The
1314        // F18 idle end of a zero-turn session counts — it leaves Live too.
1315        let park_due = self
1316            .park_deadline
1317            .filter(|d| !parked && Some(*d) != self.park_recheck_at)
1318            .filter(|_| self.can_end_idle() || self.can_park())
1319            .and_then(wall);
1320        let evict_due = self.park_deadline.filter(|_| parked).and_then(wall);
1321        let (title, cost_usd, messages) = if self.conv.is_live() {
1322            (
1323                conversation_title(&self.conv),
1324                self.conv.session_cost,
1325                self.conv.api_messages.len(),
1326            )
1327        } else {
1328            (prev.title.clone(), prev.cost_usd, prev.messages)
1329        };
1330        self.presence.store(Arc::new(Presence {
1331            clients: self.attached.len(),
1332            input_owner: self.input_owner,
1333            awaiting_input: self.pending_prompts.len(),
1334            roster: self.roster(),
1335            client_input: Some(self.client_input),
1336            busy,
1337            state,
1338            state_since,
1339            park_due,
1340            evict_due,
1341            title,
1342            cost_usd,
1343            messages,
1344        }));
1345    }
1346
1347    /// Every attached client, by id.
1348    pub(crate) fn roster(&self) -> Vec<ClientInfo> {
1349        let mut roster: Vec<ClientInfo> = self
1350            .attached
1351            .iter()
1352            .map(|(id, a)| ClientInfo {
1353                id: *id,
1354                name: a.name.clone(),
1355                kind: a.meta.kind,
1356                mode: a.mode,
1357                attached_at_ms: a.attached_at_ms,
1358            })
1359            .collect();
1360        roster.sort_by_key(|c| c.id.0);
1361        roster
1362    }
1363
1364    /// `tui (#1)`, or `client #1` for an id that isn't attached.
1365    fn client_label(&self, id: ClientId) -> String {
1366        match self.attached.get(&id) {
1367            Some(a) => format!("{} (#{})", a.name, id.0),
1368            None => format!("client #{}", id.0),
1369        }
1370    }
1371
1372    /// Why client `c` may not send an input command, if it may not.
1373    /// `exclusive`: anyone but the owner. `shared`: observers (and clients
1374    /// that aren't attached); every drive client may.
1375    fn input_refusal(&self, c: ClientId) -> Option<String> {
1376        match self.client_input {
1377            ClientInput::Exclusive => (self.input_owner != Some(c)).then(|| match self.input_owner {
1378                Some(o) => format!("input owned by {}", self.client_label(o)),
1379                None => "input has no owner; attach with --takeover".to_string(),
1380            }),
1381            ClientInput::Shared => match self.attached.get(&c).map(|a| a.mode) {
1382                Some(AttachMode::Mirror | AttachMode::Takeover) => None,
1383                Some(AttachMode::Observe) => {
1384                    Some("this client is read-only (attached with --observe)".to_string())
1385                }
1386                None => Some("this client is not attached".to_string()),
1387            },
1388        }
1389    }
1390
1391    /// Client `c` sent input (or answered a prompt): remember when, and with
1392    /// `client_input = shared` make it the active client. Observers never
1393    /// become active.
1394    fn note_input(&mut self, c: ClientId) {
1395        let Some(a) = self.attached.get_mut(&c) else { return };
1396        if a.mode == AttachMode::Observe {
1397            return;
1398        }
1399        self.input_seq += 1;
1400        a.last_input = self.input_seq;
1401        if self.client_input == ClientInput::Shared && self.input_owner != Some(c) {
1402            let from = self.input_owner;
1403            self.input_owner = Some(c);
1404            self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason: OwnerChangeReason::Input });
1405            self.publish_presence();
1406        }
1407    }
1408
1409    /// Recomputes `state` from clients/streaming and (re)arms or disarms the
1410    /// park grace timer. A no-op while Parking/Parked.
1411    pub(crate) fn update_attach_state(&mut self) {
1412        self.publish_presence();
1413        if matches!(self.state, AttachState::Parking | AttachState::Parked) {
1414            return;
1415        }
1416        self.state = if self.attached.is_empty() {
1417            AttachState::Detached {
1418                running: self.streaming,
1419            }
1420        } else {
1421            AttachState::Attached(self.attached.len())
1422        };
1423        self.rearm_park();
1424        self.rearm_prompt_abandon();
1425    }
1426
1427    // ── Parked (B3) ──────────────────────────────────────────────────────
1428
1429    pub(crate) fn is_parked(&self) -> bool {
1430        matches!(self.state, AttachState::Parked)
1431    }
1432
1433    /// A session with nothing to save (`ConversationState::save` skips an
1434    /// empty `api_messages`, so no journal ever exists) never parks: it
1435    /// costs nothing warm and could not be unparked from disk (H2).
1436    pub(crate) fn can_park(&self) -> bool {
1437        // An active cache keep-warm period defers the idle auto-park (its
1438        // cost is bounded by the ski-rental ledger); when it ends,
1439        // `rearm_park`. An explicit `Park` ends the period first.
1440        self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
1441    }
1442
1443    /// `can_park` minus the keep-warm deferral.
1444    pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
1445        self.can_park_but_for_workers() && !self.workers_hold_park()
1446    }
1447
1448    /// Whether parking would cost a worker (`SubagentRegistry::holds_park`):
1449    /// the park waits and re-checks every [`WORKER_PARK_RECHECK`].
1450    fn workers_hold_park(&self) -> bool {
1451        self.runtime.is_live()
1452            && self
1453                .runtime
1454                .subagent_registry()
1455                .lock()
1456                .unwrap_or_else(|p| p.into_inner())
1457                .holds_park()
1458    }
1459
1460    /// Every park condition except the session's workers.
1461    fn can_park_but_for_workers(&self) -> bool {
1462        self.attached.is_empty()
1463            && !self.streaming
1464            && self.compact.is_none()
1465            && self.pending_prompts.is_empty()
1466            && self.conv.is_live()
1467            && !self.conv.api_messages.is_empty()
1468            && self.conv.queued_message.is_none()
1469            && !self.keep_warm
1470            && self.config.persist
1471            && !self.is_parked()
1472            && self.driver.is_none()
1473    }
1474
1475    /// (E-P7, §S3) Return the first breached spend ceiling, if any, as
1476    /// `(scope, cost_observed, cap)`. Checks the host-owned per-session cap
1477    /// (`config.max_session_cost`) and, when a driver is armed, the effective
1478    /// per-run cap (`min(plugin grant, host)`) measured from `cost_at_arm`.
1479    /// `None` means every configured ceiling still has headroom.
1480    fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
1481        let total = self.conv.session_cost;
1482        if let Some(cap) = self.config.max_session_cost {
1483            if cap.is_finite() && total >= cap {
1484                return Some(("session", total, cap));
1485            }
1486        }
1487        if let Some(driver) = &self.driver {
1488            // Host cap for the run is not yet a config field (S3 should-have
1489            // #4, daemon-level); pass `None` so the plugin's proposal stands
1490            // alone until the daemon cap lands.
1491            if let Some(cap) = driver.grant.effective_cost_cap(None) {
1492                let run_cost = total - driver.cost_at_arm;
1493                if cap.is_finite() && run_cost >= cap {
1494                    return Some(("run", run_cost, cap));
1495                }
1496            }
1497        }
1498        None
1499    }
1500
1501    /// `<sessions>/<id>.json` — written by both persistence modes.
1502    fn journal_exists(&self) -> bool {        let id = (**self.journal_id.load()).clone();
1503        crate::config::resolve_write_path("sessions")
1504            .join(format!("{id}.json"))
1505            .is_file()
1506    }
1507
1508    // ── E: driver arm/revoke ─────────────────────────────────────────────
1509
1510    /// Invalidate the driver: drop `DriverState`, cancel pending work, emit
1511    /// `DriverRevoked` with any undelivered steering, and release the host grant.
1512    pub(crate) fn driver_revoke(&mut self, reason: &str) {
1513        let undelivered = self
1514            .driver
1515            .as_mut()
1516            .map(|d| d.steering.drain(..).collect::<Vec<_>>())
1517            .unwrap_or_default();
1518        let was_active = self.driver.is_some() || self.driver_pending.is_some();
1519        // Dropping the driver below cancels the driver turn's token.
1520        if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
1521            self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
1522        }
1523        if let Some(driver) = &self.driver {
1524            self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1525            self.host
1526                .release_driver(&driver.grant.plugin_id, &self.id);
1527        }
1528        self.driver_generation = self.driver_generation.wrapping_add(1);
1529        self.driver_pending = None;
1530        self.driver = None;
1531        if was_active {
1532            self.emit(SessionEventWire::DriverRevoked {
1533                reason: reason.to_string(),
1534                undelivered_steering: undelivered,
1535            });
1536        }
1537        // Dropping `DriverState` cancelled the driver turn's token (a child
1538        // of the driver's): that turn is over, but not through `cancel_turn`.
1539        // Unwind prompts now (as `cancel_turn` does), then give its stream
1540        // the drain budget to deliver its cancel-path history and terminal
1541        // event (`finish_revoked_turn`); a stream that never started (its
1542        // start task was just aborted) ends at the next loop turn. A caller
1543        // that cancels the turn itself right after (`finish`, `checkpoint`,
1544        // the cost cap) supersedes this: `cancel_turn` clears the deadline.
1545        if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1546            self.resolve_pending_prompts();
1547            let wait = if self.stream.is_some() {
1548                budgets::CANCEL_DRAIN_TIMEOUT
1549            } else {
1550                std::time::Duration::ZERO
1551            };
1552            self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1553        }
1554        self.rearm_park();
1555    }
1556
1557    /// Spawn the plugin's start command as an async task; the result is
1558    /// processed in `driver_tick()` (P4). Invokes the command via the
1559    /// extension manager, just like the TUI's `start_command`.
1560    pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1561        // Revoke any existing driver first (TUI :369).
1562        if self.driver.is_some() || self.driver_pending.is_some() {
1563            self.driver_revoke("explicit command");
1564        }
1565
1566        let manager = self.host.ext_manager().clone();
1567        let session_id = self.id.0.clone();
1568
1569        // Resolve handler + check permissions synchronously.
1570        let (handler, timeout) = match manager.try_read() {
1571            Ok(mgr) => {
1572                let handler = match mgr.user_action_handler(&plugin) {
1573                    Ok(h) => h,
1574                    Err(error) => {
1575                        self.emit(SessionEventWire::SystemNotice(error));
1576                        return;
1577                    }
1578                };
1579                let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1580                    5
1581                } else {
1582                    self.emit(SessionEventWire::SystemNotice(
1583                        "session driver extension lacks validated session.drive permission".into(),
1584                    ));
1585                    return;
1586                };
1587                (handler, timeout)
1588            }
1589            Err(_) => {
1590                self.emit(SessionEventWire::SystemNotice(
1591                    "extensions are loading or busy — try again shortly".into(),
1592                ));
1593                return;
1594            }
1595        };
1596
1597        let handler_generation = super::driver::live_generation(&handler).ok();
1598
1599        let owner = plugin.clone();
1600        let cmd = command.clone();
1601        let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1602
1603        let generation = self.driver_generation;
1604        let task_handler = handler.clone();
1605        self.driver_pending = Some(super::driver::DriverPending {
1606            generation,
1607            session_id: session_id.clone(),
1608            task: super::driver::Task(tokio::spawn(async move {
1609                let (sink, collector) =
1610                    invoke_event_channel(InvokeOutputBudget::default());
1611                let request_id = uuid::Uuid::new_v4().to_string();
1612                let (result, report) =
1613                    tokio::time::timeout(Duration::from_secs(timeout), async {
1614                        tokio::join!(
1615                            task_handler.invoke_command(&cmd, args, &request_id, sink),
1616                            collector.collect()
1617                        )
1618                    })
1619                    .await
1620                    .unwrap_or_else(|_| {
1621                        (
1622                            Err(format!("interactive command timed out ({timeout}s)")),
1623                            crate::extensions::invoke_output::InvokeOutputReport {
1624                                events: Vec::new(),
1625                                counters: Default::default(),
1626                            },
1627                        )
1628                    });
1629                super::driver::TaskResult::Command {
1630                    owner,
1631                    command,
1632                    handler: task_handler,
1633                    handler_generation,
1634                    result,
1635                    report,
1636                }
1637            })),
1638        });
1639    }
1640
1641    /// Arm the driver after a successful start command result. Mirrors
1642    /// the TUI's `arm()` (:538-602). Called from P4's `driver_tick()`.
1643    pub(crate) fn driver_arm(
1644        &mut self,
1645        owner: String,
1646        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1647        handler_generation: u64,
1648        reply: Reply,
1649    ) -> std::result::Result<(), String> {
1650        let manager = self.host.ext_manager().clone();
1651        super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1652
1653        if self.driver.is_some() {
1654            return Err("cannot replace an armed grant".into());
1655        }
1656
1657        let session_id = self.id.0.clone();
1658        let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1659
1660        // Claim the host-level single-tenancy grant.
1661        self.host
1662            .claim_driver(&owner, &self.id)
1663            .map_err(|e| e.to_string())?;
1664
1665        let cancel = CancellationToken::new();
1666        let workers = self.runtime.subagent_registry().clone();
1667        let worker_epoch = workers
1668            .lock()
1669            .unwrap_or_else(|p| p.into_inner())
1670            .set_spawn_cancellation(Some(cancel.clone()));
1671        let deadline_task = grant.deadline().map(|deadline| {
1672            let c = cancel.clone();
1673            let w = workers.clone();
1674            super::driver::Task(tokio::spawn(async move {
1675                tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1676                c.cancel();
1677                super::driver::cancel_workers(&w, worker_epoch);
1678            }))
1679        });
1680
1681        let models = grant.models().to_vec();
1682        let proposal_notice = proposal.notice.clone();
1683        let selection = proposal.selection.clone();
1684        let deadline_ms = super::driver::deadline_ms(&grant);
1685        let run_id = grant.run_id.clone();
1686        let cost_at_arm = self.conv.session_cost;
1687
1688        let mut state = super::driver::DriverState {
1689            selection: proposal.selection.clone(),
1690            grant,
1691            handler,
1692            handler_generation,
1693            cancel,
1694            workers,
1695            worker_epoch,
1696            deadline_task,
1697            proposal: None,
1698            awaiting_terminal: false,
1699            outcome: None,
1700            feedback: crate::extensions::feedback::Tracker::default(),
1701            completed_feedback: "unknown",
1702            steering: VecDeque::new(),
1703            auto_wakes_blocked: false,
1704            cost_at_arm,
1705        };
1706
1707        super::driver::schedule(&mut state, proposal)?;
1708
1709        // Apply context mode (session-only, like /context auto/off).
1710        let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1711        if let Some(text) = context_notice {
1712            self.emit(SessionEventWire::SystemNotice(text));
1713        }
1714
1715        self.driver_interrupted_owner = None;
1716        self.driver = Some(state);
1717
1718        self.emit(SessionEventWire::DriverArmed {
1719            plugin_id: owner,
1720            run_id,
1721            models,
1722            selection,
1723            deadline_ms,
1724            notice: proposal_notice,
1725        });
1726
1727        Ok(())
1728    }
1729
1730    // ── autonomous work: the reload fence and on_session_start ───────────
1731
1732    /// Whether autonomous work may start now, and if not, why. Two states
1733    /// hold it back: a reload's fence (#255) until the exec or its abort, and
1734    /// this session's `on_session_start` until it has run. `Fenced` wins when
1735    /// both hold. Each source of autonomous work asks [`Self::holds`]; none
1736    /// reads the fence or the hook's state itself.
1737    ///
1738    /// | source          | `Fenced`  | `SessionStartPending`                                         |
1739    /// |-----------------|-----------|---------------------------------------------------------------|
1740    /// | queue wake      | waits     | runs: the turn starts, and its request waits for the hook      |
1741    /// | park timer      | waits     | runs: a park that happens takes a hook not called out of line  |
1742    /// | keep-warm timer | waits     | waits (timer off): a due ping goes out once the hook has run   |
1743    /// | driver tick     | waits     | runs: an armed driver is revoked (`driver_idle_conflict`)      |
1744    /// | driver start    | refused * | rejected in the tick (`driver_idle_conflict`)                  |
1745    ///
1746    /// \* `DriverStart` is a command: under the fence the command fence
1747    /// refuses it first ("daemon reloading"); the tick never sees it.
1748    ///
1749    /// The queue wake must not wait for the hook: a parked session's hook is
1750    /// out of line (`SessionStart::Pending`) and joins it only at the unpark,
1751    /// and the unpark is what the queue wake does. Waiting there would leave
1752    /// an event for a parked session queued for good. The turn's own wait
1753    /// (`open_turn_stream` after `session_start_ready`) holds its request
1754    /// instead, without blocking the loop.
1755    pub(crate) fn autonomous_work_blocked(&self) -> Option<WorkBlock> {
1756        if self.reload_fence.is_some() {
1757            Some(WorkBlock::Fenced)
1758        } else if !self.session_start.is_done() {
1759            Some(WorkBlock::SessionStartPending)
1760        } else {
1761            None
1762        }
1763    }
1764
1765    /// Whether `source` waits now (the table on
1766    /// [`Self::autonomous_work_blocked`]).
1767    pub(crate) fn holds(&self, source: WorkSource) -> bool {
1768        match self.autonomous_work_blocked() {
1769            None => false,
1770            Some(WorkBlock::Fenced) => true,
1771            Some(WorkBlock::SessionStartPending) => matches!(source, WorkSource::KeepWarmTimer),
1772        }
1773    }
1774
1775    // ── E-P4: driver_tick ────────────────────────────────────────────────
1776
1777    /// Actor-equivalent of the TUI's `idle_conflict()` (TUI :437-463).
1778    /// Returns `Some(reason)` when the driver must defer or revoke.
1779    fn driver_idle_conflict(&self) -> Option<&'static str> {
1780        if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1781            return Some("other queued work");
1782        }
1783        if self.compact.is_some() {
1784            return Some("compaction");
1785        }
1786        match self.autonomous_work_blocked() {
1787            Some(WorkBlock::Fenced) => return Some("daemon reloading"),
1788            Some(WorkBlock::SessionStartPending) => return Some("extensions loading or reloading"),
1789            None => {}
1790        }
1791        if self.conv.context_head.is_blocked(&self.conv.session) {
1792            return Some("unverified context head");
1793        }
1794        if self.driver_completion_blocked() {
1795            return Some("outstanding workers require collection/reconciliation");
1796        }
1797        None
1798    }
1799
1800    /// Actor-equivalent of the TUI's `completion_blocked()` (TUI :465-480).
1801    fn driver_completion_blocked(&self) -> bool {
1802        use agent_core::orchestration::CompletionGate;
1803        self.runtime.orchestration().is_some_and(|o| {
1804            !matches!(o.completion_gate(), CompletionGate::Allowed)
1805        }) || self
1806            .runtime
1807            .subagent_registry()
1808            .lock()
1809            .map(|r| {
1810                r.list_active().iter().any(|(_, _, status)| {
1811                    *status == crate::runtime::subagent::SubagentStatus::Running
1812                })
1813            })
1814            .unwrap_or(true)
1815    }
1816
1817    /// 200 ms timer arm — the core driver loop. Every invocation performs only
1818    /// bounded state transitions; all plugin and provider IO is in abort-on-drop
1819    /// spawned tasks. Mirrors the TUI's `tick()` (TUI :695-1027).
1820    pub(crate) async fn driver_tick(&mut self) {
1821        use crate::extensions::session_driver::Outcome;
1822        if self.holds(WorkSource::DriverTick) {
1823            return;
1824        }
1825
1826        // ── 1. Cleanup cancelled stream setup ────────────────────────────
1827        // (e.g. the grant's deadline cancelled the driver token before the
1828        // turn's stream started): an interrupted turn, ended like any other.
1829        if self.stream.is_none() && self.turn_cancelled() {
1830            self.finish_revoked_turn(false).await;
1831        }
1832
1833        // ── 2. Validate lifecycle ────────────────────────────────────────
1834        if let Some(driver) = &self.driver {
1835            let invalid = if driver.grant.session_id != self.id.0 {
1836                Some("session replaced".to_string())
1837            } else if driver.grant.expired() {
1838                Some("grant deadline reached".into())
1839            } else if driver.cancel.is_cancelled() {
1840                Some("canceled".into())
1841            } else {
1842                let manager = self.host.ext_manager().clone();
1843                super::driver::same_handler(
1844                    &manager,
1845                    &driver.grant.plugin_id,
1846                    &driver.handler,
1847                    driver.handler_generation,
1848                )
1849                .err()
1850            };
1851            if let Some(reason) = invalid {
1852                self.driver_revoke(&reason);
1853                return;
1854            }
1855        }
1856
1857        // ── 3. Idle conflict (only when driver is armed) ─────────────────
1858        if self.driver.is_some() {
1859            // (E-P7, §S1) No clients → no headless spend. `detach()` revokes on
1860            // the last-client transition; this is the belt-and-suspenders check
1861            // for any path that leaves a grant armed with zero clients.
1862            if self.attached.is_empty() {
1863                self.driver_revoke("no clients attached");
1864                return;
1865            }
1866            // F10/F14: use the full driver_idle_conflict() instead of an
1867            // inline subset that was missing `completion_blocked`.
1868            if let Some(reason) = self.driver_idle_conflict() {
1869                self.driver_revoke(reason);
1870                return;
1871            }
1872
1873            // Let the reactor classify queued events before deciding whether they
1874            // are competing work or steering within this owned turn.
1875            if !self.runtime.event_queue().is_empty() {
1876                return;
1877            }
1878        }
1879
1880        // ── 4. Process finished pending task ─────────────────────────────
1881        if self
1882            .driver_pending
1883            .as_ref()
1884            .is_some_and(|p| p.task.0.is_finished())
1885        {
1886            let mut pending = self.driver_pending.take().expect("checked pending");
1887            if pending.generation != self.driver_generation
1888                || pending.session_id != self.id.0
1889            {
1890                return;
1891            }
1892            let result = match (&mut pending.task.0).await {
1893                Ok(result) => result,
1894                Err(error) => {
1895                    self.driver_revoke("driver task failed");
1896                    self.emit(SessionEventWire::SystemNotice(format!(
1897                        "Session driver task failed: {error}"
1898                    )));
1899                    return;
1900                }
1901            };
1902            match result {
1903                super::driver::TaskResult::Command {
1904                    owner,
1905                    command: _,
1906                    handler,
1907                    handler_generation,
1908                    result,
1909                    report,
1910                } => {
1911                    let current = self
1912                        .host
1913                        .ext_manager()
1914                        .try_read()
1915                        .ok()
1916                        .and_then(|m| m.user_action_handler(&owner).ok());
1917                    if !current
1918                        .as_ref()
1919                        .is_some_and(|current| Arc::ptr_eq(current, &handler))
1920                    {
1921                        self.driver_revoke(
1922                            "command owner unloaded, replaced, or unavailable",
1923                        );
1924                        return;
1925                    }
1926                    let output_failed = report.is_limited()
1927                        || report.events.iter().any(|event| {
1928                            matches!(
1929                                event,
1930                                crate::extensions::runtime::InvokeCommandEvent::Output(
1931                                    crate::extensions::commands::CommandOutputEvent::Error { .. }
1932                                )
1933                            )
1934                        });
1935                    // F7 (shady should-fix): plugin command output is visible
1936                    // to the user, but BATCHED into one notice (cap 20 lines) —
1937                    // a chatty `/auto start` must not flood the client with one
1938                    // wire event per line.
1939                    let mut lines: Vec<String> = Vec::new();
1940                    for event in &report.events {
1941                        if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1942                            let text = match out {
1943                                crate::extensions::commands::CommandOutputEvent::Text { content }
1944                                | crate::extensions::commands::CommandOutputEvent::System { content } => {
1945                                    Some(content.clone())
1946                                }
1947                                crate::extensions::commands::CommandOutputEvent::Error { content } => {
1948                                    Some(format!("Error: {content}"))
1949                                }
1950                                _ => None,
1951                            };
1952                            if let Some(text) = text {
1953                                lines.push(text);
1954                            }
1955                        }
1956                    }
1957                    if !lines.is_empty() {
1958                        const MAX_LINES: usize = 20;
1959                        let truncated = lines.len() > MAX_LINES;
1960                        if truncated {
1961                            lines.truncate(MAX_LINES);
1962                            lines.push("…output truncated".into());
1963                        }
1964                        self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1965                    }
1966                    if let Some(notice) = report.limit_notice() {
1967                        self.emit(SessionEventWire::SystemNotice(format!(
1968                            "command output truncated: {}",
1969                            notice.message,
1970                        )));
1971                    }
1972                    if let Ok(value) = result {
1973                        let result = match protocol::parse_reply(&value) {
1974                            Ok(Some(reply @ Reply::Start { .. })) => {
1975                                if output_failed {
1976                                    Err("command reported an error or exceeded its output budget"
1977                                        .into())
1978                                } else if self.streaming || self.stream.is_some() {
1979                                    Err("foreground turn still active".into())
1980                                } else if let Some(reason) = self.driver_idle_conflict() {
1981                                    Err(reason.into())
1982                                } else {
1983                                    match handler_generation {
1984                                        Some(generation) => {
1985                                            self.driver_arm(
1986                                                owner, handler, generation, reply,
1987                                            )
1988                                        }
1989                                        None => Err(
1990                                            "command owner did not expose a live lifecycle at dispatch"
1991                                                .into(),
1992                                        ),
1993                                    }
1994                                }
1995                            }
1996                            Ok(Some(
1997                                Reply::Stop { notice: text }
1998                                | Reply::Status { notice: text },
1999                            )) => {
2000                                self.emit(SessionEventWire::SystemNotice(text));
2001                                Ok(())
2002                            }
2003                            Ok(Some(Reply::Next { .. })) => Err(
2004                                "next requires an armed poll, not an interactive command"
2005                                    .into(),
2006                            ),
2007                            Ok(None) => Ok(()),
2008                            Err(error) => Err(error),
2009                        };
2010                        if let Err(error) = result {
2011                            self.emit(SessionEventWire::SystemNotice(format!(
2012                                "Session driver rejected: {error}"
2013                            )));
2014                        }
2015                    }
2016                }
2017                super::driver::TaskResult::Poll(result) => {
2018                    let Some(driver) = self.driver.as_mut() else {
2019                        return;
2020                    };
2021                    let stop_notice = match &result {
2022                        Ok(Reply::Stop { notice }) => Some(notice.clone()),
2023                        _ => None,
2024                    };
2025                    match result.and_then(|reply| driver.grant.accept(reply)) {
2026                        Ok(Some(proposal)) => {
2027                            let text = proposal.notice.clone();
2028                            if let Err(error) =
2029                                super::driver::schedule(driver, proposal)
2030                            {
2031                                self.driver_revoke(&error);
2032                            } else {
2033                                self.emit(SessionEventWire::SystemNotice(text));
2034                            }
2035                        }
2036                        Ok(None) => {
2037                            self.driver_revoke("owner requested stop");
2038                            if let Some(text) = stop_notice {
2039                                self.emit(SessionEventWire::SystemNotice(text));
2040                            }
2041                        }
2042                        Err(error) => {
2043                            self.driver_revoke(&format!("invalid poll response: {error}"))
2044                        }
2045                    }
2046                }
2047                super::driver::TaskResult::Prepared { proposal, result } => {
2048                    if self.streaming || self.stream.is_some() {
2049                        self.driver_revoke("foreground work took priority");
2050                        return;
2051                    }
2052                    // This includes a pending `on_session_start`: a driver
2053                    // turn opens its stream below, not through `start_turn`,
2054                    // so it never waits for the hook and must not run before it.
2055                    if let Some(reason) = self.driver_idle_conflict() {
2056                        self.driver_revoke(reason);
2057                        return;
2058                    }
2059                    match *result {
2060                        Err(protocol::PrepareError::Selection(error)) => {
2061                            self.emit(SessionEventWire::SystemNotice(format!(
2062                                "Session driver selection rejected (nothing sent): {error}"
2063                            )));
2064                            if let Some(driver) = self.driver.as_mut() {
2065                                driver.outcome = Some((
2066                                    Outcome::SelectionRejected,
2067                                    "unknown".into(),
2068                                ));
2069                            }
2070                        }
2071                        Err(protocol::PrepareError::Blocked(error)) => {
2072                            self.driver_revoke(&format!("preflight blocked: {error}"));
2073                        }
2074                        Ok(candidate) => {
2075                            let validated = proposal.clone();
2076                            let history = super::driver::history_with_steering(
2077                                &self.conv.api_messages,
2078                                &self
2079                                    .driver
2080                                    .as_ref()
2081                                    .map(|d| &d.steering)
2082                                    .cloned()
2083                                    .unwrap_or_default(),
2084                            );
2085                            if let Err(error) = protocol::validate_prepared(
2086                                &candidate, &validated, &history,
2087                            ) {
2088                                self.driver_revoke(&format!(
2089                                    "prepared selection changed before commit: {error}"
2090                                ));
2091                                return;
2092                            }
2093                            // F3: commit steering + prompt via the
2094                            // driver.rs helper (single call site).
2095                            // Destructure to split borrows across conv fields.
2096                            {
2097                                let driver = self.driver.as_mut().expect("driver present");
2098                                let conv = &mut *self.conv;
2099                                super::driver::commit_submission(
2100                                    &mut conv.api_messages,
2101                                    &mut driver.steering,
2102                                    &proposal.prompt,
2103                                );
2104                            }
2105                            // Apply the prepared runtime.
2106                            *self.runtime = candidate;
2107                            self.conv.session.model = self.runtime.model().to_owned();
2108                            self.conv.session.thinking_level =
2109                                self.runtime.thinking_level().to_owned();
2110                            self.consecutive_auto_turns = 0;
2111                            self.turn_baseline = self.conv.api_messages.len();
2112                            self.turn_steered_events.clear();
2113                            self.turn_replay.clear();
2114                            self.streaming = true;
2115                            self.open_turn_draft();
2116                            self.update_attach_state();
2117                            self.emit(SessionEventWire::TurnStarted {
2118                                turn_baseline: self.turn_baseline,
2119                                trigger: TurnTrigger::DriverAuto,
2120                                user_text: Some(proposal.prompt.clone()),
2121                            });
2122                            self.announce_web_search_state();
2123
2124                            // Set up the driver's awaiting_terminal.
2125                            if let Some(driver) = self.driver.as_mut() {
2126                                driver.awaiting_terminal = true;
2127                                driver.completed_feedback = "unknown";
2128                                if driver.grant.feedback_enabled() {
2129                                    driver.feedback.begin_turn(
2130                                        &driver.selection.model,
2131                                        &driver.selection.effort,
2132                                    );
2133                                }
2134                            }
2135
2136                            // Spawn stream start as a task (never block tick).
2137                            let handler = self
2138                                .driver
2139                                .as_ref()
2140                                .expect("driver")
2141                                .handler
2142                                .clone();
2143                            let handler_generation = self
2144                                .driver
2145                                .as_ref()
2146                                .expect("driver")
2147                                .handler_generation;
2148                            let ct = self
2149                                .driver
2150                                .as_ref()
2151                                .expect("driver")
2152                                .cancel
2153                                .child_token();
2154                            self.cancel = Some(ct.clone());
2155                            // Cancelled by anything that notes no cause (the
2156                            // grant's deadline task): the driver ending.
2157                            let completion = crate::runtime::TurnCompletion::with_default_cause(
2158                                crate::runtime::CancelCause::Driver,
2159                            );
2160                            self.turn_completion = Some(completion.clone());
2161                            let (tx, rx) = mpsc::unbounded_channel();
2162                            self.steer_tx = Some(tx);
2163                            self.owner_steer_budget = None;
2164                            let mut runtime = self.runtime.clone();
2165                            // F2: share the parent's TTL latches so the
2166                            // driver turn doesn't fire a redundant 1h→5m
2167                            // downgrade notice.
2168                            runtime.share_ttl_latches(&self.runtime);
2169                            let history = self.conv.api_messages.clone();
2170                            let secret = self.secret_prompt_handle.clone();
2171                            let session_id = self.id.0.clone();
2172                            let generation = self.driver_generation;
2173                            self.driver_pending = Some(super::driver::DriverPending {
2174                                generation,
2175                                session_id,
2176                                task: super::driver::Task(tokio::spawn(async move {
2177                                    if ct.is_cancelled() {
2178                                        return super::driver::TaskResult::Started(
2179                                            Err("canceled before stream setup".into()),
2180                                        );
2181                                    }
2182                                    if let Err(error) =
2183                                        super::driver::same_lifecycle(
2184                                            &handler,
2185                                            handler_generation,
2186                                        )
2187                                    {
2188                                        return super::driver::TaskResult::Started(
2189                                            Err(error),
2190                                        );
2191                                    }
2192                                    tokio::select! {
2193                                        biased;
2194                                        _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
2195                                        started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
2196                                    }
2197                                })),
2198                            });
2199
2200                            // Start subagent tick (same as normal start_turn).
2201                            let mut tick = tokio::time::interval(
2202                                std::time::Duration::from_secs(1),
2203                            );
2204                            tick.set_missed_tick_behavior(
2205                                tokio::time::MissedTickBehavior::Delay,
2206                            );
2207                            tick.reset();
2208                            self.subagent_tick = Some(tick);
2209                            self.request_save();
2210                        }
2211                    }
2212                }
2213                super::driver::TaskResult::Started(started) => match started {
2214                    Ok(started) if self.driver.is_some() => {
2215                        self.stream = Some(started);
2216                    }
2217                    Err(error) => {
2218                        self.driver_revoke(&error);
2219                    }
2220                    _ => {}
2221                },
2222            }
2223        }
2224
2225        // ── 5. Idle scheduling ───────────────────────────────────────────
2226        if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
2227            return;
2228        }
2229        if let Some(reason) = self.driver_idle_conflict() {
2230            self.driver_revoke(reason);
2231            return;
2232        }
2233        let session_cost = self.conv.session_cost;
2234        let Some(driver) = self.driver.as_mut() else {
2235            return;
2236        };
2237        if let Some((outcome, error_kind)) = driver.outcome.take() {
2238            let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
2239            let handler = driver.handler.clone();
2240            let session_id = self.id.0.clone();
2241            let generation = self.driver_generation;
2242            self.driver_pending = Some(super::driver::DriverPending {
2243                generation,
2244                session_id,
2245                task: super::driver::Task(tokio::spawn(async move {
2246                    // F4: poll must not hang forever — 30 s timeout like
2247                    // the prepare path's 5 s.
2248                    let result = tokio::time::timeout(
2249                        Duration::from_secs(30),
2250                        protocol::poll(handler, request),
2251                    )
2252                    .await
2253                    .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
2254                    super::driver::TaskResult::Poll(result)
2255                })),
2256            });
2257        } else if driver
2258            .proposal
2259            .as_ref()
2260            .is_some_and(|p| Instant::now() >= p.due)
2261        {
2262            let proposal =
2263                driver.proposal.take().expect("checked proposal").proposal;
2264            // Clone is read-only (prepare never sends a request) and
2265            // discarded — no TTL latch sharing needed.
2266            let runtime = self.runtime.clone();
2267            let history = super::driver::history_with_steering(
2268                &self.conv.api_messages,
2269                &driver.steering,
2270            );
2271            let session_id = self.id.0.clone();
2272            let generation = self.driver_generation;
2273            self.driver_pending = Some(super::driver::DriverPending {
2274                generation,
2275                session_id,
2276                task: super::driver::Task(tokio::spawn(async move {
2277                    let result = tokio::time::timeout(
2278                        Duration::from_secs(5),
2279                        protocol::prepare(&runtime, &proposal, &history),
2280                    )
2281                    .await
2282                    .unwrap_or_else(|_| {
2283                        Err(protocol::PrepareError::Blocked(
2284                            "preparation timed out (5s)".into(),
2285                        ))
2286                    });
2287                    super::driver::TaskResult::Prepared {
2288                        proposal,
2289                        result: Box::new(result),
2290                    }
2291                })),
2292            });
2293        }
2294    }
2295
2296    /// F10: the session lock follows the conversation to `new_id` (a fresh
2297    /// id from NewSession or compaction). Best-effort: on failure the old
2298    /// lock is still released (the conversation left it) and the failure
2299    /// logged. The new lock is taken BEFORE the old one is dropped.
2300    pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
2301        match Self::try_lock_session(new_id) {
2302            Ok(lock) => self.session_lock = Some(lock),
2303            Err(e) => {
2304                tracing::warn!(session = %new_id, "reacquire session lock: {e}");
2305                self.session_lock = None;
2306            }
2307        }
2308    }
2309
2310    /// Try to take the journal lock on `id` for this daemon process.
2311    pub(crate) fn try_lock_session(
2312        id: &str,
2313    ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
2314    {
2315        let dir = agent_core::session_lock::sessions_dir();
2316        let holder = agent_core::session_lock::LockHolder {
2317            pid: std::process::id(),
2318            kind: "daemon".to_string(),
2319        };
2320        agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
2321    }
2322
2323    /// F18: a session with NO history can never park (nothing to journal),
2324    /// so with no clients it would stay Live — Runtime resident — forever.
2325    /// Same gates as `can_park` minus the history requirement: at the park
2326    /// deadline such a session ends instead.
2327    pub(crate) fn can_end_idle(&self) -> bool {
2328        self.attached.is_empty()
2329            && !self.streaming
2330            && self.compact.is_none()
2331            && self.pending_prompts.is_empty()
2332            && self.conv.is_live()
2333            && self.conv.api_messages.is_empty()
2334            && self.conv.queued_message.is_none()
2335            && !self.keep_warm
2336            && !self.is_parked()
2337            // An armed driver keeps the session alive between turns exactly
2338            // like an attached client would (shady P3/P4 F6: without this, a
2339            // zero-turn armed session is idle-ended 5 s after the last client
2340            // detaches, killing the run mid-arm). `can_park` already guards.
2341            && self.driver.is_none()
2342            && self.driver_pending.is_none()
2343    }
2344
2345    pub(crate) fn rearm_park(&mut self) {
2346        self.rearm_park_deadline();
2347        self.publish_presence();
2348    }
2349
2350    fn rearm_park_deadline(&mut self) {
2351        if self.is_parked() {
2352            // Eviction deadline is owned by `park()`; the attach path unparks
2353            // (which rebuilds the runtime and clears the deadline).
2354            return;
2355        }
2356        if self.can_end_idle() {
2357            // F18: nothing to keep warm — a zero-turn session with no
2358            // clients ends after a short grace, not the full park grace
2359            // (which exists to keep a runtime WITH history warm for a
2360            // quick reconnect).
2361            if self.park_deadline.is_none() {
2362                self.park_deadline =
2363                    Some(tokio::time::Instant::now() + idle_end_grace());
2364            }
2365            return;
2366        }
2367        if !self.can_park() {
2368            // Only workers in the way: look again later (a worker finishing
2369            // or being collected does not always pass through here).
2370            let workers_only = self.can_park_but_for_workers()
2371                && !self.cache_keepwarm.active()
2372                && park_grace().is_some();
2373            self.park_deadline = if workers_only {
2374                self.park_deadline.or_else(|| {
2375                    let at = tokio::time::Instant::now() + worker_park_recheck();
2376                    self.park_recheck_at = Some(at);
2377                    Some(at)
2378                })
2379            } else {
2380                None
2381            };
2382            return;
2383        }
2384        match park_grace() {
2385            Some(grace) => {
2386                // The earlier of a pending deadline and a fresh grace: a
2387                // grace armed at detach keeps counting, while a far-off
2388                // worker re-check gives way once the workers let go.
2389                let due = tokio::time::Instant::now() + grace;
2390                // A re-check due before the grace stays a re-check: when it
2391                // fires it arms the grace rather than parking early.
2392                self.park_deadline = Some(self.park_deadline.map_or(due, |d| d.min(due)));
2393            }
2394            None => self.park_deadline = None,
2395        }
2396    }
2397
2398    /// (P11) Bound the resource pin from an ABANDONED prompt. A pending host
2399    /// confirmation on a plain interactive session survives detach so the user
2400    /// reattaches and answers it — but if NObody ever comes back it pins the
2401    /// session forever (`can_park()` requires `pending_prompts` empty). Arm a
2402    /// deadline only in that abandoned state (zero clients, prompt pending, no
2403    /// driver); a re-attach clears it (prompt survives, user answers); expiry
2404    /// denies the prompt(s). The driver-armed case fail-closes immediately in
2405    /// `detach` and never reaches here. Idempotent: safe to call on every
2406    /// attach/detach/prompt transition.
2407    fn rearm_prompt_abandon(&mut self) {
2408        let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
2409        let abandoned = self.attached.is_empty()
2410            && !self.pending_prompts.is_empty()
2411            && !armed_driver
2412            && !self.is_parked();
2413        if !abandoned {
2414            // Someone is attached, the prompt was answered, or a driver took
2415            // over the fail-closed path — never auto-deny while any of these.
2416            self.prompt_abandon_deadline = None;
2417            return;
2418        }
2419        if self.prompt_abandon_deadline.is_none() {
2420            // `None` config ⇒ deadline stays `None` ⇒ disabled (pre-#112).
2421            self.prompt_abandon_deadline =
2422                prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
2423        }
2424    }
2425
2426    /// (P11) The prompt-abandonment deadline expired with still zero clients:
2427    /// deny every pending prompt (send `None` + emit `PromptResolved`) so the
2428    /// session stops zombie-blocking and `can_park()` becomes true. A re-attach
2429    /// would have cleared the deadline first; re-check defensively.
2430    fn on_prompt_abandon_deadline(&mut self) {
2431        self.prompt_abandon_deadline = None;
2432        if !self.attached.is_empty() {
2433            return;
2434        }
2435        let had_prompts = !self.pending_prompts.is_empty();
2436        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2437            let _ = tx.send(None);
2438            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2439        }
2440        if had_prompts {
2441            tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
2442            self.publish_presence();
2443            // `can_park()` may now be true — re-evaluate the park deadline.
2444            self.rearm_park();
2445        }
2446    }
2447
2448    fn set_lifecycle(&mut self, l: SessionLifecycle) {
2449        self.lifecycle
2450            .store(l as u8, std::sync::atomic::Ordering::Release);
2451        self.emit(SessionEventWire::Lifecycle(l));
2452    }
2453
2454    /// Save, close PTYs, drop `conv` THEN `runtime`. `background` (inbox
2455    /// watcher + per-session UDS + registry) stays: `synaps send` keeps
2456    /// resolving and its push into `event_queue` is the wake-up.
2457    pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
2458        let flow = self.park_now().await;
2459        self.publish_presence();
2460        // Restored to be parked but still live (it could not park): its
2461        // `on_session_start` runs now, as for any live session.
2462        if flow.is_continue()
2463            && !self.is_parked()
2464            && matches!(self.session_start, super::session_start::SessionStart::Pending)
2465        {
2466            self.queue_session_start();
2467        }
2468        flow
2469    }
2470
2471    async fn park_now(&mut self) -> std::ops::ControlFlow<EndReason> {
2472        // Fired by a worker re-check (not the grace, not an explicit Park)?
2473        let recheck = self.park_deadline.is_some() && self.park_deadline == self.park_recheck_at;
2474        self.park_recheck_at = None;
2475        self.park_deadline = None;
2476        if self.is_parked() {
2477            if self.attached.is_empty() {
2478                tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
2479                return std::ops::ControlFlow::Break(EndReason::Evicted);
2480            }
2481            return std::ops::ControlFlow::Continue(());
2482        }
2483        if self.can_end_idle() {
2484            tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
2485            // No journal will ever exist for this id: take the lock file
2486            // with us instead of leaving an orphan `.lock` (RC soak F-NEW-1).
2487            if let Some(lock) = self.session_lock.take() {
2488                lock.release_and_remove();
2489            }
2490            return std::ops::ControlFlow::Break(EndReason::Idle);
2491        }
2492        if !self.can_park() {
2493            // Re-arms a re-check while only workers hold the park.
2494            self.rearm_park();
2495            return std::ops::ControlFlow::Continue(());
2496        }
2497        if recheck {
2498            // The workers let go: now the reconnect grace runs, from here.
2499            // A re-check is not the user's grace having elapsed.
2500            if let Some(grace) = park_grace() {
2501                self.park_deadline = Some(tokio::time::Instant::now() + grace);
2502                return std::ops::ControlFlow::Continue(());
2503            }
2504        }
2505        self.state = AttachState::Parking;
2506        self.set_lifecycle(SessionLifecycle::Parking);
2507        // Parking drops the conversation from memory: everything queued must
2508        // be on disk first, and a failed save keeps the session live.
2509        if !self.save().await {
2510            tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
2511            self.state = AttachState::Detached { running: false };
2512            self.set_lifecycle(SessionLifecycle::Live);
2513            return std::ops::ControlFlow::Continue(());
2514        }
2515        if !self.journal_exists() {
2516            // Never park what cannot be restored (H2).
2517            tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
2518            self.state = AttachState::Detached { running: false };
2519            self.set_lifecycle(SessionLifecycle::Live);
2520            return std::ops::ControlFlow::Continue(());
2521        }
2522        if self.runtime.session_manager().active_count() > 0 {
2523            self.emit(SessionEventWire::SystemNotice(
2524                "parked: background shells closed".into(),
2525            ));
2526        }
2527        self.runtime.session_manager().shutdown_all();
2528        self.turn_replay.clear();
2529        // The unparked runtime is built from `config.memory_mode`: a
2530        // revocation made without `/memory` must be recorded first.
2531        self.sync_memory_choice();
2532        // The runtime's exact tool activations outlive it: the unparked
2533        // runtime restores them, so the tools array (the start of the cached
2534        // prompt prefix) and the model's tools are what they were.
2535        self.parked_tool_activations = self.runtime.carried_tool_activations();
2536        // Drop order: conv first, then runtime — a panic between them can
2537        // never leave a Runtime without state to save.
2538        // Last look at the conversation for listings (title/cost/messages).
2539        self.publish_presence();
2540        let conv = self.conv.park_take();
2541        drop(conv);
2542        let runtime = self.runtime.park_take();
2543        drop(runtime);
2544        // Hand the freed pages back: jemalloc otherwise keeps them as
2545        // dirty/muzzy for its decay window, and a parked session that
2546        // still shows in RssAnon buys nothing.
2547        crate::core::memstat::purge_arenas();
2548        // F10: release the journal lock so an in-process --continue can
2549        // pick up the parked session (the daemon will fail to re-acquire
2550        // on unpark and surface a clear error instead of a zombie).
2551        self.session_lock = None;
2552        // A hook not called yet waits for the unpark, out of line.
2553        self.session_start.withdraw_for_park();
2554        self.state = AttachState::Parked;
2555        self.set_lifecycle(SessionLifecycle::Parked);
2556        tracing::info!(session = %self.id, "session parked");
2557        // The park timer is reused as the eviction timer: a session nobody
2558        // re-attaches to within `parked_evict_after` leaves the map (its
2559        // journal stays; `--continue` brings it back).
2560        self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
2561        std::ops::ControlFlow::Continue(())
2562    }
2563
2564    /// Rebuild `runtime` + `conv` from the journal (`load_session_in_dir`
2565    /// via `resolve_session_and_prompt`), replay non-persisted settings,
2566    /// `finish_session_setup(IndexRecord::Skip)`. On error the session
2567    /// stays Parked (nothing is half-built).
2568    pub(crate) async fn unpark(&mut self) -> Result<()> {
2569        let started = std::time::Instant::now();
2570        // Somebody came back: the eviction deadline armed by `park()` is void.
2571        self.park_deadline = None;
2572        let journal_id = (**self.journal_id.load()).clone();
2573
2574        // F10: re-acquire the journal lock before rebuilding the runtime.
2575        // If an in-process --continue took over while we were parked, fail
2576        // loudly so the attach gets a clear error instead of a zombie.
2577        {
2578            let dir = agent_core::session_lock::sessions_dir();
2579            let holder = agent_core::session_lock::LockHolder {
2580                pid: std::process::id(),
2581                kind: "daemon".to_string(),
2582            };
2583            match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
2584                Ok(lock) => self.session_lock = Some(lock),
2585                Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
2586                    return Err(crate::RuntimeError::Session(format!(
2587                        "cannot unpark session {}: journal locked by another process",
2588                        journal_id
2589                    )));
2590                }
2591                Err(e) => {
2592                    tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
2593                    // Non-fatal: proceed without the lock (e.g. read-only fs).
2594                }
2595            }
2596        }
2597
2598        let host = Arc::clone(&self.host);
2599        let cfg = self.config.clone();
2600        let queue = Arc::clone(&self.event_queue);
2601        let replay = self.settings_replay.clone();
2602        let journal_present = self.journal_exists();
2603        let view = self.view.load_full();
2604        let build = async move {
2605            let config: crate::SynapsConfig = (**host.config()).clone();
2606            // §4: cwd before apply_config — see `create`. The session's own
2607            // `/memory` choice, if it made one, instead of the default.
2608            let mut runtime = host
2609                .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
2610                .await?;
2611            if let Some(mode) = cfg.memory_mode {
2612                runtime.restore_session_memory_mode(mode);
2613            }
2614            runtime.set_event_queue(queue);
2615            let mut sb = if journal_present {
2616                crate::engine::setup::resolve_session_and_prompt(
2617                    &mut runtime,
2618                    &Some(Some(journal_id)),
2619                    cfg.system.as_deref(),
2620                    cfg.prompt_manifest.as_deref(),
2621                )?
2622            } else {
2623                // The journal vanished under us (H2): rebuild an empty
2624                // conversation under the SAME id with the last-published
2625                // model/thinking rather than leaving a zombie Parked session.
2626                tracing::warn!(
2627                    session = %journal_id,
2628                    "unpark: journal missing — restoring as a fresh conversation"
2629                );
2630                let mut sb = crate::engine::setup::resolve_session_and_prompt(
2631                    &mut runtime,
2632                    &None,
2633                    cfg.system.as_deref(),
2634                    cfg.prompt_manifest.as_deref(),
2635                )?;
2636                sb.session.id = journal_id.clone();
2637                runtime.set_session_id(Some(journal_id));
2638                sb
2639            };
2640            runtime.set_cwd(cfg.cwd.clone());
2641            runtime.set_env(cfg.env.clone());
2642            runtime.set_env_stripped(cfg.env_stripped.clone());
2643            // The CURRENT model/thinking (the last published view), not
2644            // `cfg.model_override` frozen at create: `/model` survives park.
2645            runtime.set_model(view.model.clone());
2646            let _ = runtime.restore_session_reasoning(&view.thinking_level);
2647            sb.session.model = view.model.clone();
2648            sb.session.thinking_level = runtime.thinking_level().to_string();
2649            crate::engine::setup::finish_session_setup(
2650                &mut runtime,
2651                &config,
2652                &sb.session,
2653                cfg.cwd.clone(),
2654                crate::engine::setup::IndexRecord::Skip,
2655            );
2656            for s in &replay {
2657                replay_setting(&mut runtime, s);
2658            }
2659            Ok::<_, crate::RuntimeError>((runtime, sb))
2660        };
2661        let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2662            Ok(Ok(v)) => v,
2663            Ok(Err(e)) => return Err(e),
2664            Err(_) => {
2665                return Err(crate::RuntimeError::Session(format!(
2666                    "unpark timed out after {}s",
2667                    budgets::UNPARK_TIMEOUT_SECS
2668                )))
2669            }
2670        };
2671        let mut conv = ConversationState::from_resumed(sb.session);
2672        conv.api_messages = sb.api_messages;
2673        conv.total_input_tokens = sb.total_input_tokens;
2674        conv.total_output_tokens = sb.total_output_tokens;
2675        conv.session_cost = sb.session_cost;
2676        if journal_present && self.config.persist && self.session_lock.is_some() {
2677            crate::engine::setup::recover_turn_draft(&mut conv).await;
2678        }
2679        runtime.restore_tool_activations(std::mem::take(&mut self.parked_tool_activations));
2680        self.runtime.unpark_set(runtime);
2681        self.conv.unpark_set(conv);
2682        // A fresh runtime: whether its grant took effect is its own.
2683        self.memory_seen_active = false;
2684        self.observe_memory();
2685        self.publish_view().await;
2686        self.state = AttachState::Detached { running: false };
2687        self.set_lifecycle(SessionLifecycle::Live);
2688        self.update_attach_state();
2689        // A hook that waited for the unpark runs now (once per session).
2690        if matches!(self.session_start, super::session_start::SessionStart::Pending) {
2691            self.queue_session_start();
2692        }
2693        tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2694        Ok(())
2695    }
2696
2697    /// Unpark if Parked. `Err` = still Parked; the caller reports it
2698    /// (`Refused` to a client, `AttachRefused` to an attacher, a warning
2699    /// for host wakes).
2700    pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2701        if !self.is_parked() {
2702            return Ok(());
2703        }
2704        let res = self
2705            .unpark()
2706            .await
2707            .map_err(|e| format!("session parked and could not be restored: {e}"));
2708        self.publish_presence();
2709        res
2710    }
2711
2712    /// `stream_handler.rs:264`: a turn OR a compaction blocks auto-turns.
2713    pub(crate) fn busy(&self) -> bool {
2714        self.streaming || self.compact.is_some()
2715    }
2716
2717    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`, only
2718    /// when there is something to show (the TUI's reconcile is a no-op on
2719    /// an empty registry).
2720    ///
2721    /// The 1 Hz tick that drives this starts with a turn and outlives it
2722    /// while a background worker (`subagent_start`) is still running: those
2723    /// workers' progress events rode the ended turn's stream, so these rows
2724    /// (which carry each worker's step and tool count) are how clients keep
2725    /// up. The tick stops once no worker is running after the turn, having
2726    /// published the terminal rows.
2727    pub(crate) fn publish_subagent_rows(&mut self) {
2728        let rows = self
2729            .runtime
2730            .subagent_registry()
2731            .lock()
2732            .unwrap_or_else(|p| p.into_inner())
2733            .display_rows();
2734        let running = rows
2735            .iter()
2736            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2737        if !rows.is_empty() {
2738            self.emit(SessionEventWire::SubagentRows(rows));
2739        }
2740        if !keep_subagent_tick(self.streaming, running) {
2741            self.subagent_tick = None;
2742            // The last worker finished: a park it held may be due now.
2743            self.rearm_park();
2744        }
2745    }
2746
2747    /// Whether any worker in the registry is still running.
2748    fn subagents_running(&self) -> bool {
2749        self.runtime
2750            .subagent_registry()
2751            .lock()
2752            .unwrap_or_else(|p| p.into_inner())
2753            .display_rows()
2754            .iter()
2755            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2756    }
2757
2758    /// Codex hosted web_search is read from this (engine/daemon) process's
2759    /// env, so say so in the session — once per session per model/state,
2760    /// inside the turn so its replay ring carries it to clients attaching
2761    /// mid-turn.
2762    fn announce_web_search_state(&mut self) {
2763        let notice = crate::runtime::openai::codex_wire::session_web_search_notice(
2764            self.runtime.model(),
2765            crate::runtime::openai::codex_wire::engine_env,
2766        );
2767        if notice.is_some() && notice != self.web_search_notice {
2768            self.web_search_notice = notice.clone();
2769            if let Some(text) = notice {
2770                self.emit(SessionEventWire::Stream(StreamEvent::Session(
2771                    SessionEvent::Notice(text),
2772                )));
2773            }
2774        }
2775    }
2776
2777    // ── turn start (dispatch.rs Submit tail / stream_handler.rs RunTurn) ──
2778
2779    /// This session's `on_session_start` task ended: the hook ran (or the
2780    /// task failed, which counts as done: fail-open like a hook timeout).
2781    /// Refresh the view, which shows the injection, then send what waited
2782    /// for it: a started turn's request. A due keep-warm ping waited with its
2783    /// timer off (`holds`); the loop's next pass arms it again.
2784    async fn session_start_finished(
2785        &mut self,
2786        ran: std::result::Result<bool, tokio::task::JoinError>,
2787    ) {
2788        use super::session_start::SessionStart;
2789        match ran {
2790            Ok(true) => {}
2791            // Withdrawn before it was called (a park): not run yet. Anything
2792            // waiting for it puts it back in line.
2793            Ok(false) => {
2794                self.session_start = SessionStart::Pending;
2795                if self.turn_awaiting_start.is_some() || self.cache_keepwarm.deadline().is_some() {
2796                    self.session_start_ready();
2797                }
2798                return;
2799            }
2800            Err(e) => tracing::warn!(session = %self.id, "on_session_start task failed: {e}"),
2801        }
2802        self.session_start = SessionStart::Done;
2803        if self.runtime.is_live() {
2804            self.publish_view().await;
2805        }
2806        if let Some(start) = self.turn_awaiting_start.take() {
2807            // A turn canceled from outside (a driver revocation) ends on its
2808            // own path (`finish_revoked_turn`) with nothing sent.
2809            if !start.cancel.is_cancelled() {
2810                self.open_turn_stream(start).await;
2811            }
2812        }
2813    }
2814
2815    /// No turn and no keep-warm ping goes out before `on_session_start` has
2816    /// run. Its injection is part of their system prompt, so a request sent
2817    /// before it would lack the extension context and the next request would
2818    /// change the cached system prompt. (A manual `/compact` does not wait:
2819    /// it builds its own prompt; a rolling summary reuses the request of the
2820    /// turn before it or builds its own prompt.) A session created with
2821    /// `await_extensions=false` (quick start, every session a reload
2822    /// restores) has a task in line at the host's gate for it, or, while
2823    /// parked, nothing in line until the unpark.
2824    ///
2825    /// `true` once the hook has run. Otherwise it puts the hook in line if
2826    /// it is not (after a park) and moves it ahead of background hooks; the
2827    /// caller waits for `session_start_finished` without blocking the loop.
2828    /// The hook itself always runs in that task, through the gate, so it is
2829    /// never called twice or beside another session's.
2830    pub(crate) fn session_start_ready(&mut self) -> bool {
2831        use super::session_start::SessionStart;
2832        if matches!(self.session_start, SessionStart::Pending) {
2833            self.queue_session_start();
2834        }
2835        match &self.session_start {
2836            SessionStart::Done => true,
2837            SessionStart::Queued { ticket, .. } => {
2838                ticket.prefer();
2839                false
2840            }
2841            SessionStart::Pending => false,
2842        }
2843    }
2844
2845    /// Put this session's `on_session_start` in line (in the background).
2846    fn queue_session_start(&mut self) {
2847        self.session_start = super::session_start::SessionStart::queue(
2848            &self.host,
2849            Arc::clone(&self.hook_bus),
2850            Arc::clone(&self.journal_id),
2851            extensions_ready_timeout(&self.host.config()),
2852        );
2853    }
2854
2855    /// Open the turn's stream: the request goes out.
2856    async fn open_turn_stream(&mut self, start: TurnStart) {
2857        // A turn started by events (nobody typed it) runs under the event
2858        // turn budget for this turn only; the stream copies the budget when
2859        // it starts, so the session budget is restored right after. Here and
2860        // not in `start_turn`: a turn whose request waited for
2861        // `on_session_start` opens its stream later, under its own budget.
2862        let session_budget = start.event_turn.then(|| {
2863            let event_budget = self
2864                .runtime
2865                .turn_budget()
2866                .for_event_turn(&self.host.config().turn_budgets);
2867            let session_budget = self.runtime.turn_budget().clone();
2868            self.runtime.set_turn_budget(event_budget);
2869            self.runtime
2870                .set_owner_steer_budget(self.owner_steer_budget.clone());
2871            session_budget
2872        });
2873        let stream = self
2874            .runtime
2875            .run_stream_tracked(
2876                self.conv.api_messages.clone(),
2877                start.cancel,
2878                Some(start.steer_rx),
2879                Some(self.secret_prompt_handle.clone()),
2880                start.auto_approve,
2881                start.completion,
2882            )
2883            .await;
2884        if let Some(session_budget) = session_budget {
2885            self.runtime.set_turn_budget(session_budget);
2886            self.runtime.set_owner_steer_budget(None);
2887        }
2888        self.stream = Some(stream);
2889    }
2890
2891    /// Start a turn. Its request goes out at once, or, while this session's
2892    /// `on_session_start` has not run, once it has (`session_start_ready`);
2893    /// the turn is live from here either way.
2894    pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2895        self.cache_keepwarm_turn_started();
2896        let ct = CancellationToken::new();
2897        let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2898        self.streaming = true;
2899        self.turn_baseline = self.conv.api_messages.len();
2900        self.open_turn_draft();
2901        self.turn_steered_events.clear();
2902        self.turn_replay.clear();
2903        self.update_attach_state();
2904        self.emit(SessionEventWire::TurnStarted {
2905            turn_baseline: self.turn_baseline,
2906            trigger,
2907            user_text,
2908        });
2909        self.announce_web_search_state();
2910        // Blocks command processing during setup exactly like the TUI loop.
2911        // (E-P7, §S9) An armed driver MUST NOT auto-approve tool activation —
2912        // the session-driver contract keeps ordinary tool-approval gates in
2913        // force. Override the session config to false whenever a grant is
2914        // armed, regardless of what the client requested. (Driver-initiated
2915        // turns already pass `false` from the tick's Prepared arm; this guards
2916        // any foreground turn that starts while armed.)
2917        let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2918        // Stamp this turn's worker launches before the stream can start any.
2919        // A grant made since the last turn (e.g. while the session was
2920        // being rebuilt) is seen before this turn can revoke it.
2921        self.observe_memory();
2922        self.turn_worker_generation = self
2923            .runtime
2924            .subagent_registry()
2925            .lock()
2926            .unwrap_or_else(|p| p.into_inner())
2927            .begin_turn();
2928        let completion = crate::runtime::TurnCompletion::new();
2929        self.turn_completion = Some(completion.clone());
2930        self.cancel = Some(ct.clone());
2931        self.steer_tx = Some(s_tx);
2932        let event_turn = trigger == TurnTrigger::EventAuto;
2933        self.owner_steer_budget = event_turn.then(Default::default);
2934        let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2935        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2936        tick.reset();
2937        self.subagent_tick = Some(tick);
2938        let start = TurnStart {
2939            cancel: ct,
2940            steer_rx: s_rx,
2941            auto_approve,
2942            completion,
2943            event_turn,
2944        };
2945        if self.session_start_ready() {
2946            self.open_turn_stream(start).await;
2947        } else {
2948            tracing::debug!(session = %self.id, "turn waits for on_session_start");
2949            self.turn_awaiting_start = Some(start);
2950            // Quick Start's first turn can wait here for extension discovery
2951            // (`startup.extensions_ready_timeout_secs`) and then the hook:
2952            // say so, or the silence looks like a hang.
2953            self.emit(SessionEventWire::SystemNotice(
2954                "waiting for extensions to start (on_session_start) before sending...".into(),
2955            ));
2956        }
2957    }
2958
2959    /// Every turn-end path (Done/Error/Cancel/stream EOF). `streaming=false`
2960    /// makes a `Cancel` racing a `Done` the idle no-op. Events steered into
2961    /// the stream that the engine never took (`turn_steered_events`) go back
2962    /// to `pending_events`, first: they are flushed into history like any
2963    /// event that arrived during the turn (the interrupted path takes them
2964    /// earlier, to place them after the marker). Queues the save of the
2965    /// turn's final history, then the draft removal (`close_turn_draft`).
2966    pub(crate) fn clear_stream(&mut self) {
2967        self.stream = None;
2968        self.turn_awaiting_start = None;
2969        self.cancel = None;
2970        self.steer_tx = None;
2971        self.owner_steer_budget = None;
2972        self.streaming = false;
2973        self.turn_completion = None;
2974        self.revoked_turn_deadline = None;
2975        // Background workers outlive the turn: keep publishing their rows
2976        // until the last one finishes (see `publish_subagent_rows`).
2977        if !keep_subagent_tick(false, self.subagents_running()) {
2978            self.subagent_tick = None;
2979        }
2980        if !self.turn_steered_events.is_empty() && self.conv.is_live() {
2981            let undelivered = std::mem::take(&mut self.turn_steered_events);
2982            self.conv.pending_events.splice(0..0, undelivered);
2983        }
2984        // The history that ends the turn — whether or not a draft is open
2985        // (no draft without the session lock) — then the draft removal.
2986        self.request_save();
2987        self.close_turn_draft();
2988        self.update_attach_state();
2989    }
2990
2991    // ── in-flight turn draft (crash recovery) ──────────────────────────
2992
2993    /// Turn start: the draft's existence is the "turn open" signal a
2994    /// loader uses to detect a process that died mid-turn. Only the session
2995    /// lock holder writes one: without the lock another process may own
2996    /// this session, and a draft is a claim that a turn is running HERE.
2997    fn open_turn_draft(&mut self) {
2998        let persist =
2999            self.config.persist && self.conv.is_live() && self.session_lock.is_some();
3000        self.turn_draft = TurnDraftState {
3001            open: persist.then(|| self.conv.session.id.clone()),
3002            base_len: self.conv.api_messages.len(),
3003            text: String::new(),
3004            tools: Vec::new(),
3005            dirty: true,
3006        };
3007        self.flush_turn_draft();
3008    }
3009
3010    fn turn_draft_text(&mut self, delta: &str) {
3011        let d = &mut self.turn_draft;
3012        if d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES {
3013            d.text.push_str(delta);
3014            d.dirty = true;
3015        }
3016    }
3017
3018    fn turn_draft_reset_text(&mut self) {
3019        let d = &mut self.turn_draft;
3020        d.tools.clear();
3021        if !d.text.is_empty() {
3022            d.text.clear();
3023            d.dirty = true;
3024        }
3025    }
3026
3027    /// A round checkpoint was adopted and saved: later text continues from it.
3028    fn turn_draft_committed(&mut self) {
3029        self.turn_draft.base_len = self.conv.api_messages.len();
3030        self.turn_draft.text.clear();
3031        self.turn_draft.tools.clear();
3032        self.turn_draft.dirty = true;
3033        self.flush_turn_draft();
3034    }
3035
3036    /// A context rollover replaced the history head, and `base_len` no
3037    /// longer refers to it. Until the engine's next round checkpoint
3038    /// re-bases the draft, nothing streamed can be placed: an impossible
3039    /// length makes the cancel fallback (`keep_streamed_partial`) and crash
3040    /// recovery (`recover_crashed_turn`) both skip the text.
3041    fn turn_draft_rolled_over(&mut self) {
3042        self.turn_draft.base_len = usize::MAX;
3043        self.turn_draft.text.clear();
3044        self.turn_draft.tools.clear();
3045        self.turn_draft.dirty = true;
3046        self.flush_turn_draft();
3047    }
3048
3049    /// Follow the in-flight response for the draft: its text, a new
3050    /// response or retry starting over, the tools it calls. Shared by the
3051    /// stream handler and the cancel drain, so text the actor only receives
3052    /// while draining (already shown to clients) is tracked too.
3053    fn observe_turn_draft(&mut self, event: &crate::LlmEvent) {
3054        match event {
3055            crate::LlmEvent::Text(text) => self.turn_draft_text(text),
3056            crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset => {
3057                self.turn_draft_reset_text()
3058            }
3059            crate::LlmEvent::ToolUse { tool_name, .. } => {
3060                self.turn_draft.tools.push(tool_name.clone())
3061            }
3062            _ => {}
3063        }
3064    }
3065
3066    /// Write the draft if it changed (1 Hz turn tick; round checkpoints).
3067    /// Non-blocking and ordered (`persister`).
3068    pub(crate) fn flush_turn_draft(&mut self) {
3069        let Some(open) = self.turn_draft.open.clone() else {
3070            return;
3071        };
3072        if !self.turn_draft.dirty {
3073            return;
3074        }
3075        // Defensive: follow the conversation if its id changed mid-turn.
3076        let id = self.conv.session.id.clone();
3077        if open != id {
3078            self.persister.remove_draft(&open);
3079            self.turn_draft.open = Some(id.clone());
3080        }
3081        self.turn_draft.dirty = false;
3082        self.persister.write_draft(
3083            &id,
3084            agent_core::core::session_draft::TurnDraft {
3085                base_len: self.turn_draft.base_len,
3086                partial_text: self.turn_draft.text.clone(),
3087            },
3088        );
3089    }
3090
3091    /// Turn end (every path goes through `clear_stream`, which has just
3092    /// queued the save of the history that ends the turn): remove the draft.
3093    /// Queued after that save, and the persister never removes a draft while
3094    /// its session's latest save has failed, so the draft cannot disappear
3095    /// before the history that concludes its turn is on disk.
3096    fn close_turn_draft(&mut self) {
3097        if let Some(id) = self.turn_draft.open.take() {
3098            self.persister.remove_draft(&id);
3099        }
3100        self.turn_draft = TurnDraftState::default();
3101    }
3102
3103    /// dispatch.rs Submit (:1231-1288) minus presentation.
3104    pub(crate) async fn submit(
3105        &mut self,
3106        text: String,
3107        attachments: Vec<serde_json::Value>,
3108        from: Option<ClientId>,
3109    ) {
3110        // Wall 1 defense-in-depth: a latched (unverified) context head must
3111        // not accept new inference. The stream would refuse via
3112        // `durability_blocked` anyway, but that leaves the user message
3113        // orphaned in history; refuse here, before it is pushed.
3114        if self.conv.context_head.is_blocked(&self.conv.session) {
3115            let reason = "context head is unverified after a failed checkpoint; reload the \
3116                          session (`--continue`) or start a new one before continuing"
3117                .to_string();
3118            // Refused to the submitter (its editor text comes back and it
3119            // stops expecting this turn), a notice for everyone else.
3120            match from {
3121                Some(client) => self.emit(SessionEventWire::Refused {
3122                    client,
3123                    command: "submit".into(),
3124                    reason,
3125                }),
3126                None => self.emit(SessionEventWire::SystemNotice(reason)),
3127            }
3128            return;
3129        }
3130        if self.streaming {
3131            // A Submit while streaming is what the TUI calls StreamingInput.
3132            // Attachments during streaming are rejected by the client; if they
3133            // arrive anyway, ignore them (text-only steer).
3134            // P6: if the driver is armed and streaming, steer AND update
3135            // the driver's steering FIFO.
3136            if let Some(driver) = self.driver.as_mut() {
3137                let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3138                if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3139                    self.emit(SessionEventWire::SystemNotice(
3140                        "steering queue full (16 messages) — message dropped".into(),
3141                    ));
3142                    return;
3143                }
3144                if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3145                    self.emit(SessionEventWire::SystemNotice(
3146                        "steering queue full (256 KiB) — message dropped".into(),
3147                    ));
3148                    return;
3149                }
3150                driver.steering.push_back(text.clone());
3151            }
3152            self.steer(text);
3153            return;
3154        }
3155        // P6: while the driver is armed and idle, route to steering FIFO
3156        // instead of starting a normal turn.
3157        if self.driver.is_some() {
3158            let driver = self.driver.as_mut().unwrap();
3159            let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3160            if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3161                self.emit(SessionEventWire::SystemNotice(
3162                    "steering queue full (16 messages) — message dropped".into(),
3163                ));
3164                return;
3165            }
3166            if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3167                self.emit(SessionEventWire::SystemNotice(
3168                    "steering queue full (256 KiB) — message dropped".into(),
3169                ));
3170                return;
3171            }
3172            driver.steering.push_back(text.clone());
3173            // Clear auto_wakes_blocked on explicit user submit (user takeover).
3174            driver.auto_wakes_blocked = false;
3175            self.emit(SessionEventWire::Steered {
3176                text,
3177                delivered: false,
3178            });
3179            return;
3180        }
3181        if self.compact.is_some() {
3182            // dispatch.rs:1233-1237: queued until the compaction lands.
3183            self.emit(SessionEventWire::Steered {
3184                text: text.clone(),
3185                delivered: false,
3186            });
3187            self.conv.queued_message = Some(text);
3188            return;
3189        }
3190        // Real user send — reset auto-turn counter.
3191        self.consecutive_auto_turns = 0;
3192        // For every OTHER attached client: the submitter drew its own card.
3193        let user_text = text.clone();
3194        let api_content = text;
3195
3196        if attachments.is_empty() {
3197            // Text-only: existing path.
3198            self.conv.api_messages.push(std::sync::Arc::new(
3199                serde_json::json!({"role": "user", "content": api_content}),
3200            ));
3201        } else {
3202            // Build multipart content: text block (if non-empty) + attachment blocks.
3203            let mut blocks = Vec::with_capacity(attachments.len() + 1);
3204            if !api_content.is_empty() {
3205                blocks.push(serde_json::json!({"type": "text", "text": api_content}));
3206            }
3207            blocks.extend(attachments);
3208            let candidate = std::sync::Arc::new(
3209                serde_json::json!({"role": "user", "content": blocks}),
3210            );
3211            // Validate the complete history (including this message) for the
3212            // session's current model before accepting.
3213            let mut proposed = self.conv.api_messages.clone();
3214            proposed.push(candidate.clone());
3215            let model = self.runtime.model().to_string();
3216            if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
3217                // Typed refusal: the submitting client gets its editor text
3218                // back (stream_handler restores `last_submitted` on `Refused`)
3219                // and keeps its attachment drafts; mirrors see a notice.
3220                match from {
3221                    Some(client) => self.emit(SessionEventWire::Refused {
3222                        client,
3223                        command: "submit".into(),
3224                        reason: format!("attachments rejected: {e}"),
3225                    }),
3226                    None => self.emit(SessionEventWire::SystemNotice(format!(
3227                        "attachments rejected: {e}"
3228                    ))),
3229                }
3230                return;
3231            }
3232            self.conv.api_messages.push(candidate);
3233        }
3234        self.start_turn(TurnTrigger::User, Some(user_text)).await;
3235    }
3236
3237    /// dispatch.rs StreamingInput plain-text branch (:1369-1378).
3238    ///
3239    /// Only the input owner steers (`is_input_command`). In an event-started
3240    /// turn, the owner's message also moves the turn onto the session budget
3241    /// (`self.runtime.turn_budget()`, which a `/budget` changes) once the
3242    /// stream delivers it. A steer that misses the turn (its stream ended)
3243    /// stays queued and starts a typed turn, on that budget anyway.
3244    pub(crate) fn steer(&mut self, text: String) {
3245        let send = |tx: &mpsc::UnboundedSender<String>| tx.send(text.clone()).is_ok();
3246        let delivered = match (self.steer_tx.as_ref(), self.owner_steer_budget.as_deref()) {
3247            (Some(tx), Some(lift)) => {
3248                lift.send_owner_steer(self.runtime.turn_budget().clone(), || send(tx))
3249            }
3250            (Some(tx), None) => send(tx),
3251            (None, _) => false,
3252        };
3253        self.emit(SessionEventWire::Steered {
3254            text: text.clone(),
3255            delivered,
3256        });
3257        self.conv.queued_message = Some(text);
3258    }
3259
3260    /// `SubagentSteer`: hand the user's text to one of this session's running
3261    /// workers, then note it in the MAIN conversation so the main agent knows
3262    /// the user redirected its worker. The note travels like a drained event
3263    /// (steered into a live turn, buffered while busy, injected while idle)
3264    /// but never starts a turn on its own; clients render it from `External`.
3265    pub(crate) fn steer_subagent(&mut self, from: Option<ClientId>, handle_id: &str, text: String) {
3266        let steered = {
3267            let registry = self
3268                .runtime
3269                .subagent_registry()
3270                .lock()
3271                .unwrap_or_else(|p| p.into_inner());
3272            match registry.get(handle_id) {
3273                None => Err(format!("no subagent {handle_id} in this session")),
3274                Some(h) => {
3275                    let running = h
3276                        .state
3277                        .read()
3278                        .unwrap_or_else(|p| p.into_inner())
3279                        .public_status()
3280                        == crate::runtime::subagent::SubagentStatus::Running;
3281                    if running {
3282                        h.steer(&text).map(|()| h.agent_name.clone())
3283                    } else {
3284                        Err(format!("{} ({handle_id}) is not running", h.agent_name))
3285                    }
3286                }
3287            }
3288        };
3289        let agent_name = match steered {
3290            Ok(name) => name,
3291            Err(reason) => {
3292                match from {
3293                    Some(c) => self.emit(SessionEventWire::Refused {
3294                        client: c,
3295                        command: "subagent_steer".to_string(),
3296                        reason,
3297                    }),
3298                    None => self.emit(SessionEventWire::SystemNotice(reason)),
3299                }
3300                return;
3301            }
3302        };
3303        self.note_in_main_conversation(subagent_steer_event(handle_id, &agent_name, &text));
3304    }
3305
3306    /// Tell the MAIN agent something the user did to its workers: the note
3307    /// travels like a drained event (steered into a live turn, buffered while
3308    /// busy, injected while idle) but never starts a turn on its own; clients
3309    /// render it from `External`.
3310    pub(crate) fn note_in_main_conversation(&mut self, event: crate::events::types::Event) {
3311        let formatted = crate::events::format_event_for_agent(&event);
3312        if self.busy() {
3313            let into_turn = self
3314                .steer_tx
3315                .as_ref()
3316                .is_some_and(|tx| tx.send(formatted.clone()).is_ok());
3317            if into_turn {
3318                self.turn_steered_events.push(formatted);
3319            } else {
3320                self.conv.pending_events.push(formatted);
3321            }
3322        } else {
3323            self.conv
3324                .api_messages
3325                .push(std::sync::Arc::new(serde_json::json!({
3326                    "role": "user",
3327                    "content": formatted
3328                })));
3329            self.request_save();
3330        }
3331        self.emit(SessionEventWire::External(event));
3332        self.emit_conversation();
3333    }
3334
3335    /// Every cancel path — `Cancel` (Esc), quit mid-turn (`finish`), the cost
3336    /// cap, a reload/host checkpoint — behind the TUI's `if streaming` guard
3337    /// (input.rs:350): a `Cancel` while idle is a no-op that only re-announces
3338    /// `Idle` — it must never touch history, save, or emit `Aborted`.
3339    ///
3340    /// The interrupted turn is recorded as REAL history, never as a recap:
3341    /// 1. the token is cancelled, then pending host prompts are answered
3342    ///    `None` (a tool or `before_tool_call` hook blocked on one must unwind);
3343    /// 2. the still-live stream is drained for a
3344    ///    bounded time (`drain_cancelled_stream`) so the engine's cancel-path
3345    ///    history — partial assistant message, completed tool rounds,
3346    ///    delivered steering, canceled `tool_result`s — plus its final Usage
3347    ///    and any in-flight context-head checkpoint reach the actor;
3348    /// 3. that history is adopted verbatim (else the last adopted history is
3349    ///    kept — every history the engine publishes is valid), then
3350    ///    `finish_interrupted_turn` APPENDS one interruption marker
3351    ///    (`engine::interrupt`). Nothing already sent is edited, so the
3352    ///    provider's cached prefix survives.
3353    ///
3354    /// A turn the engine had already finished when the cancel reached it
3355    /// (Esc a moment after the answer ended) is not interrupted: no marker.
3356    pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3357        if !self.streaming {
3358            if self.compact.is_some() {
3359                self.abort_compaction();
3360                if let Some(q) = self.conv.queued_message.take() {
3361                    self.emit(SessionEventWire::Dequeued { text: q });
3362                }
3363                self.update_attach_state();
3364            }
3365            self.emit(SessionEventWire::Idle);
3366            return;
3367        }
3368        self.note_cancel_cause(reason);
3369        if let Some(ref ct) = self.cancel {
3370            ct.cancel();
3371        }
3372        // After the cancel, so a tool awaiting a prompt observes the cancel
3373        // (not a `None` it could read as "declined"); a `before_tool_call`
3374        // hook awaiting a confirm prompt is not cancel-aware and needs this
3375        // answer to unwind at all.
3376        self.resolve_pending_prompts();
3377        let drain = self.drain_cancelled_stream().await;
3378        match drain.history {
3379            Some(history) => self.conv.api_messages = history,
3380            None => {
3381                if !drain.closed {
3382                    tracing::warn!(
3383                        session = %self.id,
3384                        budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
3385                        "cancelled turn did not publish its history within the drain budget; \
3386                         keeping the last adopted history plus the streamed partial reply"
3387                    );
3388                }
3389                self.keep_streamed_partial(reason);
3390            }
3391        }
3392        // Complete only if the drain consumed the whole stream: the final
3393        // history is published right after the completion is recorded.
3394        let completed = drain.closed && self.turn_completed();
3395        self.finish_interrupted_turn(reason, completed).await;
3396    }
3397
3398    /// An interrupted turn whose engine never published its post-cancel
3399    /// history (the drain budget ran out, or the stream ended without it):
3400    /// keep the reply text clients were already shown, as a real assistant
3401    /// message, so history matches the screen and the marker that follows
3402    /// doesn't merge into the next prompt. Same rule as crash recovery
3403    /// (`engine::interrupt::recover_crashed_turn`): only when the history is
3404    /// still exactly where that text began (`base_len`), never a stale one,
3405    /// and right after a user message. Text only, so it can't leave a
3406    /// `tool_use` without its result: an in-flight tool call and unsigned
3407    /// thinking are dropped. (Rebuilding the call isn't safe: with extended
3408    /// thinking a `tool_use` turn needs its signed thinking block.) A tool
3409    /// the response called instead gets a host-authored note after it
3410    /// (`engine::interrupt::tool_in_flight_note_message`), worded like the
3411    /// engine's canceled `tool_result`, so the next turn doesn't blindly
3412    /// repeat a non-idempotent action; clients get the same note. The
3413    /// engine's late publication is not applied: its stream is gone with the
3414    /// drain.
3415    fn keep_streamed_partial(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3416        let d = &self.turn_draft;
3417        let follows_user = self.conv.api_messages.last().is_some_and(|m| m["role"] == "user");
3418        if d.base_len != self.conv.api_messages.len() || !follows_user {
3419            return;
3420        }
3421        let (text, tools) = (d.text.clone(), d.tools.clone());
3422        if !text.trim().is_empty() {
3423            self.conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3424                "role": "assistant",
3425                "content": [{"type": "text", "text": text}]
3426            })));
3427        }
3428        if !tools.is_empty() {
3429            let note = crate::engine::interrupt::tool_in_flight_note_message(reason, &tools);
3430            let text = note["content"].as_str().unwrap_or_default().to_owned();
3431            self.conv.api_messages.push(note);
3432            self.emit(SessionEventWire::SystemNotice(text));
3433        }
3434    }
3435
3436    /// The engine recorded the running turn's normal end (`TurnCompletion`).
3437    fn turn_completed(&self) -> bool {
3438        self.turn_completion.as_ref().is_some_and(|c| c.completed())
3439    }
3440
3441    /// Tell the engine why the running turn is being cancelled, BEFORE its
3442    /// token is: its canceled tool results say so ("Canceled by user" only
3443    /// for the user). First cause wins, so a teardown that revokes a driver
3444    /// and then cancels notes its own cause first.
3445    fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
3446        if !self.streaming {
3447            return;
3448        }
3449        if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
3450            turn.note_cancel_cause(cause);
3451        }
3452    }
3453
3454    /// The ONE tail of every interrupted turn, once its stream has been
3455    /// drained or dropped (`cancel_turn`, a revoked driver turn): record the
3456    /// interruption in history, end the turn, tell clients. Never runs the
3457    /// post-turn machinery of a normal end (queued auto-send, event
3458    /// auto-turns, auto-compaction).
3459    ///
3460    /// `completed`: the engine had finished the turn normally before the
3461    /// cancel reached it — the history is a complete answer, so no marker is
3462    /// appended and clients get the `Done` they would have had.
3463    async fn finish_interrupted_turn(
3464        &mut self,
3465        reason: crate::engine::interrupt::InterruptReason,
3466        completed: bool,
3467    ) {
3468        // Defensive: only trailing invalid messages this turn appended.
3469        crate::engine::stream::repair_history_after_failure(
3470            &mut self.conv.api_messages,
3471            self.turn_baseline,
3472        );
3473        let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
3474        if !completed {
3475            crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
3476        }
3477        // A user steer the engine never picked up was not delivered.
3478        if let Some(q) = self.conv.queued_message.take() {
3479            self.emit(SessionEventWire::Dequeued { text: q });
3480        }
3481        // Events that arrived during the turn go in after the marker: first
3482        // those steered into the stream but never drained by the engine
3483        // (otherwise lost with the channel), then those buffered.
3484        {
3485            let undelivered = std::mem::take(&mut self.turn_steered_events);
3486            let conv: &mut ConversationState = &mut self.conv;
3487            for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
3488                conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3489                    "role": "user",
3490                    "content": formatted
3491                })));
3492            }
3493        }
3494        // Queues the save of this history, then the draft removal.
3495        self.clear_stream();
3496        // Cancel running reactive subagents; recover a poisoned guard rather
3497        // than skip cancellation. A user interrupt (Esc) stops the turn it
3498        // interrupted and the workers THAT turn launched; reactive workers
3499        // started by earlier turns are background work and keep running
3500        // (`/workers cancel` stops them). Every other reason — session
3501        // teardown, cost cap, host checkpoint, driver revocation — stops all.
3502        {
3503            use crate::runtime::subagent::WorkerCancelScope;
3504            let ending = self.lifecycle.load(std::sync::atomic::Ordering::Acquire)
3505                == SessionLifecycle::Ending as u8;
3506            let scope = worker_cancel_scope(reason, ending, self.turn_worker_generation);
3507            let mut registry = match self.runtime.subagent_registry().lock() {
3508                Ok(g) => g,
3509                Err(poisoned) => {
3510                    tracing::warn!(
3511                        "subagent registry mutex poisoned during abort; recovering to cancel running handles"
3512                    );
3513                    poisoned.into_inner()
3514                }
3515            };
3516            let cancelled = registry.cancel_running(scope);
3517            if !cancelled.is_empty() {
3518                tracing::info!(
3519                    ?scope,
3520                    workers = ?cancelled,
3521                    "turn interrupted: cancelled running workers"
3522                );
3523            }
3524            if let WorkerCancelScope::Turn(_) = scope {
3525                // Still running and staying so: not the ones just cancelled
3526                // (they read as running until their next step).
3527                let spared = registry
3528                    .display_rows()
3529                    .iter()
3530                    .filter(|r| {
3531                        r.status == crate::runtime::subagent::SubagentStatus::Running
3532                            && !r.cancel_requested
3533                    })
3534                    .count();
3535                drop(registry);
3536                if spared > 0 {
3537                    self.emit(SessionEventWire::Stream(StreamEvent::Session(
3538                        SessionEvent::Notice(format!(
3539                            "{spared} background worker{} from earlier turns still running — /workers to list, /workers cancel all to stop",
3540                            if spared == 1 { "" } else { "s" }
3541                        )),
3542                    )));
3543                }
3544            }
3545        }
3546        if completed {
3547            self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
3548        } else {
3549            // Typed event. `context_saved` (wire name kept for compatibility)
3550            // now means "the turn's partial work is kept in history".
3551            self.emit(SessionEventWire::Aborted {
3552                context_saved: kept_partial,
3553            });
3554        }
3555        self.emit_conversation();
3556        self.announce_idle().await;
3557    }
3558
3559    /// A turn whose token was cancelled from OUTSIDE `cancel_turn` — a
3560    /// driver revocation drops `DriverState`, cancelling the driver turn's
3561    /// child token (the grant's deadline task cancels that token too; the
3562    /// next `driver_tick` then revokes and arms the deadline) — ends here: on its `Done`, its stream's end, or when
3563    /// `revoked_turn_deadline` passes (a tool that ignores the cancel must
3564    /// not keep the session busy forever; the same budget as
3565    /// `drain_cancelled_stream`). What is left of the stream is dropped.
3566    async fn finish_revoked_turn(&mut self, stream_closed: bool) {
3567        if !self.streaming {
3568            return;
3569        }
3570        let completed = stream_closed && self.turn_completed();
3571        self.stream = None;
3572        if !completed {
3573            self.keep_streamed_partial(crate::engine::interrupt::InterruptReason::Driver);
3574        }
3575        self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
3576            .await;
3577    }
3578
3579    /// Whether the running turn's token has been cancelled (by `cancel_turn`
3580    /// or a driver revocation).
3581    fn turn_cancelled(&self) -> bool {
3582        self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
3583    }
3584
3585    /// Answer every pending host prompt `None` (same as `checkpoint`/`finish`).
3586    fn resolve_pending_prompts(&mut self) {
3587        if self.pending_prompts.is_empty() {
3588            return;
3589        }
3590        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3591            let _ = tx.send(None);
3592            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3593        }
3594        self.publish_presence();
3595        self.rearm_prompt_abandon();
3596    }
3597
3598    /// Consume what a CANCELLED turn's stream still carries, for at most
3599    /// `budgets::CANCEL_DRAIN_TIMEOUT`, then drop it.
3600    ///
3601    /// Deliberately NOT `on_stream_event`: that handler would re-check the
3602    /// cost cap on the final `Usage` (recursive `cancel_turn`), run the
3603    /// failure path on the engine's typed `Canceled` error, and treat `Done`
3604    /// as a normal completion (queued auto-send, auto-compaction).
3605    ///
3606    /// Forwarded to clients: display events (partial text, tool results —
3607    /// the history being adopted contains them), `Usage`, agent events.
3608    /// Handled: `MessageHistory` (kept, last wins), `ContextHeadCheckpoint`
3609    /// (serviced — dropping its receipt would latch `durability_blocked`;
3610    /// the head it installs replaces any history kept before it),
3611    /// `SteeringDelivered`. Swallowed: `Done`, the `Canceled` error. A typed
3612    /// `InterruptedAfterSideEffect` becomes a notice (it is not a failure of
3613    /// the turn; the canceled `tool_result` already tells the model). A tool
3614    /// raising a host prompt before it observed the cancel is answered
3615    /// `None` at once, not at the deadline.
3616    async fn drain_cancelled_stream(&mut self) -> CancelDrain {
3617        let mut out = CancelDrain::default();
3618        let Some(mut stream) = self.stream.take() else {
3619            out.closed = true;
3620            return out;
3621        };
3622        let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
3623        loop {
3624            let event = tokio::select! {
3625                biased;
3626                Some(req) = self.secret_prompt_rx.recv() => {
3627                    let _ = req.response_tx.send(None);
3628                    continue;
3629                }
3630                next = tokio::time::timeout_at(deadline, stream.next()) => match next {
3631                    Err(_) => break,
3632                    Ok(None) => {
3633                        out.closed = true;
3634                        break;
3635                    }
3636                    Ok(Some(event)) => event,
3637                },
3638            };
3639            match event {
3640                StreamEvent::Session(SessionEvent::Done) => {
3641                    out.closed = true;
3642                    break;
3643                }
3644                StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3645                    out.history = Some(history);
3646                }
3647                StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
3648                    session_id,
3649                    messages,
3650                    receipt,
3651                }) => {
3652                    self.handle_context_head_checkpoint(session_id, messages, receipt)
3653                        .await;
3654                    // The adopted head is the actor's history now: a history
3655                    // kept from BEFORE it is stale and must not override it
3656                    // (a later `MessageHistory`, if any, still wins).
3657                    out.history = None;
3658                }
3659                StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
3660                    crate::TurnOutcome::Canceled => {}
3661                    crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3662                        self.emit(SessionEventWire::SystemNotice(err.message));
3663                    }
3664                    _ => {
3665                        tracing::warn!(
3666                            session = %self.id,
3667                            category = err.category_label(),
3668                            "cancelled turn reported an error while draining"
3669                        );
3670                    }
3671                },
3672                StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3673                    self.note_steering_delivered(message);
3674                    self.emit(SessionEventWire::Stream(event));
3675                }
3676                StreamEvent::Session(SessionEvent::Usage { .. }) => {
3677                    self.record_usage(&event);
3678                    self.emit(SessionEventWire::Stream(event));
3679                }
3680                StreamEvent::Llm(ref llm) => {
3681                    self.observe_turn_draft(llm);
3682                    self.emit(SessionEventWire::Stream(event));
3683                }
3684                other => self.emit(SessionEventWire::Stream(other)),
3685            }
3686        }
3687        drop(stream);
3688        out
3689    }
3690
3691    /// `SteeringDelivered`: the engine injected `message` into history.
3692    fn note_steering_delivered(&mut self, message: &str) {
3693        if self.conv.queued_message.as_deref() == Some(message) {
3694            self.conv.queued_message = None;
3695            self.emit_conversation();
3696        }
3697        // P5/P6: pop the driver's steering FIFO on delivery ack.
3698        if let Some(driver) = self.driver.as_mut() {
3699            if driver.steering.front().map(String::as_str) == Some(message) {
3700                driver.steering.pop_front();
3701            }
3702        }
3703        if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
3704            self.turn_steered_events.remove(pos);
3705        }
3706    }
3707
3708    /// Accumulate one `SessionEvent::Usage` into the conversation totals.
3709    fn record_usage(&mut self, event: &StreamEvent) {
3710        if let StreamEvent::Session(SessionEvent::Usage {
3711            input_tokens,
3712            output_tokens,
3713            cache_read_input_tokens,
3714            cache_creation_input_tokens,
3715            cache_creation_5m,
3716            cache_creation_1h,
3717            model: usage_model,
3718        }) = event
3719        {
3720            let model_for_pricing = usage_model
3721                .as_deref()
3722                .unwrap_or(self.runtime.model())
3723                .to_string();
3724            self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
3725                input_tokens: *input_tokens,
3726                output_tokens: *output_tokens,
3727                cache_read_tokens: *cache_read_input_tokens,
3728                cache_write_tokens: *cache_creation_input_tokens,
3729                cache_write_5m: *cache_creation_5m,
3730                cache_write_1h: *cache_creation_1h,
3731                model: usage_model.clone(),
3732            });
3733            self.conv.add_usage(
3734                *input_tokens,
3735                *output_tokens,
3736                *cache_read_input_tokens,
3737                *cache_creation_input_tokens,
3738                *cache_creation_5m,
3739                *cache_creation_1h,
3740                &model_for_pricing,
3741            );
3742        }
3743    }
3744
3745    // ── event-queue wake (stream_handler.rs handle_event_queue_arm) ──────
3746
3747    async fn on_queue_wake(&mut self) {
3748        if self.holds(WorkSource::QueueWake) {
3749            // Fenced for a reload: the record carries what is queued.
3750            return;
3751        }
3752        if self.is_parked() {
3753            if self.event_queue.is_empty() {
3754                return;
3755            }
3756            if let Err(e) = self.ensure_live().await {
3757                tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
3758                return;
3759            }
3760        }
3761        let busy = self.busy();
3762        let conv: &mut ConversationState = &mut self.conv;
3763        let drained = drain_event_queue(
3764            &self.event_queue,
3765            &mut conv.api_messages,
3766            &mut conv.pending_events,
3767            busy,
3768            self.steer_tx.as_ref(),
3769        );
3770        if drained.is_empty() {
3771            return;
3772        }
3773        for de in &drained {
3774            if de.disposition == EventDisposition::Steered {
3775                self.turn_steered_events.push(de.formatted.clone());
3776            }
3777            self.emit(SessionEventWire::External(de.event.clone()));
3778        }
3779        let injected = drained
3780            .iter()
3781            .any(|d| d.disposition == EventDisposition::Injected);
3782        if injected || busy {
3783            self.emit_conversation();
3784        }
3785
3786        // P5: observe_events — if non-steering events arrive during a
3787        // driver-OWNED turn, revoke (competing work). Coordinates with F8.
3788        if self.streaming
3789            && self
3790                .driver
3791                .as_ref()
3792                .is_some_and(|d| d.awaiting_terminal)
3793        {
3794            let competing = drained
3795                .iter()
3796                .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
3797            if competing {
3798                self.driver_revoke("event-bus work took priority");
3799            }
3800        }
3801
3802        // `events.auto_turn = false` opts the session out of event-driven
3803        // turns (events are still injected/forwarded; the spend governor
3804        // for an ambient session). Read live from host config, like the
3805        // RPC/server hosts do — it used to be hardcoded `true` here.
3806        let auto_turn_enabled = self.host.config().events.auto_turn;
3807        let auto_turn_cap = self.host.config().events.auto_turn_cap;
3808        let action = wake_action_with_cap(
3809            &drained,
3810            &self.conv.api_messages,
3811            busy,
3812            auto_turn_enabled,
3813            self.consecutive_auto_turns,
3814            auto_turn_cap,
3815        );
3816        match action {
3817            WakeAction::RunTurn => {
3818                // F8: while a driver is armed, its tick owns turn scheduling.
3819                // Starting a competing turn here would race the driver.
3820                if self.driver.is_some() {
3821                    tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
3822                } else if self.streaming || self.stream.is_some() {
3823                    tracing::warn!("handle_event_arm: RunTurn with active turn — skipping");
3824                } else {
3825                    self.consecutive_auto_turns += 1;
3826                    self.start_turn(TurnTrigger::EventAuto, None).await;
3827                }
3828            }
3829            WakeAction::Forward => {
3830                let hit_cap = injected
3831                    && !busy
3832                    && auto_turn_enabled
3833                    && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
3834                if hit_cap {
3835                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3836                }
3837            }
3838            WakeAction::Nothing => {}
3839        }
3840    }
3841
3842    // ── stream events (stream_handler.rs handle_stream_event + arm tail) ──
3843
3844    async fn on_stream_event(&mut self, event: StreamEvent) {
3845        // A turn cancelled from outside `cancel_turn` (a driver revocation):
3846        // its terminal events are not a normal end — the turn is finished as
3847        // interrupted, never through the post-turn machinery below.
3848        if self.turn_cancelled() {
3849            match event {
3850                StreamEvent::Session(SessionEvent::Done) => {
3851                    self.finish_revoked_turn(true).await;
3852                    return;
3853                }
3854                // Swallowed as in `drain_cancelled_stream`; `Done` follows.
3855                StreamEvent::Session(SessionEvent::Error(err)) => {
3856                    match err.outcome {
3857                        crate::TurnOutcome::Canceled => {}
3858                        crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3859                            self.emit(SessionEventWire::SystemNotice(err.message));
3860                        }
3861                        _ => tracing::warn!(
3862                            session = %self.id,
3863                            category = err.category_label(),
3864                            "revoked turn reported an error"
3865                        ),
3866                    }
3867                    return;
3868                }
3869                _ => {}
3870            }
3871        }
3872        // Forward first: clients see the same order they see today. A
3873        // context-head checkpoint is actor-internal (its receipt is ours to
3874        // complete) and must not reach clients: the wire has no such event
3875        // and maps it to `Done`, which ended the turn on socket clients.
3876        if !matches!(
3877            event,
3878            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
3879        ) {
3880            self.emit(SessionEventWire::Stream(event.clone()));
3881        }
3882
3883        // P5: observe_feedback — feed opted-in driver turns.
3884        if let Some(driver) = self.driver.as_mut() {
3885            if driver.awaiting_terminal && driver.grant.feedback_enabled() {
3886                driver.feedback.observe(&event);
3887            }
3888        }
3889
3890        // P5: capture_terminal BEFORE Done/Error handlers modify state.
3891        let is_canceled = self
3892            .cancel
3893            .as_ref()
3894            .is_some_and(|ct| ct.is_cancelled());
3895        let terminal = if self
3896            .driver
3897            .as_ref()
3898            .is_some_and(|d| d.awaiting_terminal)
3899        {
3900            super::driver::capture_terminal(Some(&event), is_canceled)
3901        } else {
3902            None
3903        };
3904
3905        enum After {
3906            Continue,
3907            AutoSendQueued(String),
3908            AutoTriggerEvents,
3909            Failed,
3910        }
3911        let mut after = After::Continue;
3912
3913        match event {
3914            // Text, a new provider response (or a retry's reset: the
3915            // in-flight text starts over), tool calls.
3916            StreamEvent::Llm(ref llm) => self.observe_turn_draft(llm),
3917            StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3918                // Published at every round boundary as well as at the end of
3919                // the turn (`runtime/stream.rs` ROUND CHECKPOINT): the session
3920                // on disk follows the turn as it progresses. Queued, never
3921                // awaited — the persister writes it (latest wins), so a slow
3922                // disk can never stall the turn machine.
3923                self.conv.api_messages = history;
3924                self.request_save();
3925                // The round is committed: the draft now continues from here.
3926                self.turn_draft_committed();
3927                self.trim_replay_to_checkpoint();
3928                self.emit_conversation();
3929            }
3930            StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3931                self.note_steering_delivered(message);
3932            }
3933            StreamEvent::Agent(_) => {}
3934            StreamEvent::Session(SessionEvent::Usage { .. }) => {
3935                self.record_usage(&event);
3936                // (E-P7, §S3) Host-owned spend circuit breaker. Checked after
3937                // every Usage event — a breach cancels the in-flight turn,
3938                // revokes any armed driver (so it cannot re-poll and keep
3939                // spending), and tells clients why.
3940                if let Some((scope, cost, cap)) = self.cost_cap_breach() {
3941                    self.emit(SessionEventWire::CostCapReached {
3942                        scope: scope.to_string(),
3943                        cost,
3944                        cap,
3945                    });
3946                    self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
3947                    if self.driver.is_some() || self.driver_pending.is_some() {
3948                        self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
3949                    }
3950                    self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
3951                        .await;
3952                    return;
3953                }
3954            }
3955            StreamEvent::Session(SessionEvent::Notice(_) | SessionEvent::Retry(_)) => {}
3956            StreamEvent::Session(SessionEvent::Done) => {
3957                self.clear_stream();
3958                self.publish_subagent_rows();
3959                // Flush events that arrived during streaming into api_messages
3960                let had_pending = !self.conv.pending_events.is_empty();
3961                {
3962                    let conv: &mut ConversationState = &mut self.conv;
3963                    for formatted in conv.pending_events.drain(..) {
3964                        conv.api_messages
3965                            .push(std::sync::Arc::new(serde_json::json!({
3966                                "role": "user",
3967                                "content": formatted
3968                            })));
3969                    }
3970                }
3971                if let Some(queued) = self.conv.queued_message.take() {
3972                    after = After::AutoSendQueued(queued);
3973                } else if had_pending {
3974                    self.request_save();
3975                    after = After::AutoTriggerEvents;
3976                }
3977                self.emit_conversation();
3978                if matches!(after, After::Continue) {
3979                    if self.config.auto_compact {
3980                        self.post_turn_chat().await;
3981                    }
3982                    // A spawned compaction emits Idle when it lands.
3983                    if self.compact.is_none() {
3984                        self.announce_idle().await;
3985                    }
3986                }
3987            }
3988            StreamEvent::Session(SessionEvent::Error(_)) => {
3989                // Remove only invalid messages appended by the ACTIVE turn —
3990                // before `clear_stream` queues the turn's final save.
3991                crate::engine::stream::repair_history_after_failure(
3992                    &mut self.conv.api_messages,
3993                    self.turn_baseline,
3994                );
3995                self.clear_stream();
3996                self.publish_subagent_rows();
3997                self.emit_conversation();
3998                after = After::Failed;
3999            }
4000            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
4001                session_id,
4002                messages,
4003                receipt,
4004            }) => {
4005                self.handle_context_head_checkpoint(session_id, messages, receipt)
4006                    .await;
4007            }
4008        }
4009
4010        match after {
4011            After::Continue => {}
4012            After::Failed => {
4013                if self.config.auto_compact {
4014                    self.post_turn_chat().await;
4015                }
4016                if self.compact.is_none() {
4017                    self.announce_idle().await;
4018                }
4019            }
4020            After::AutoSendQueued(queued) => {
4021                if self.config.auto_compact {
4022                    self.post_turn_chat().await;
4023                }
4024                if self.compact.is_some() {
4025                    // Rides the compaction transition (queued_message
4026                    // restored last); the TUI reports it, never re-sends.
4027                    self.conv.queued_message = Some(queued);
4028                    return;
4029                }
4030                // Auto-send the queued message (user-authored — reset counter)
4031                self.consecutive_auto_turns = 0;
4032                let user_text = queued.clone();
4033                self.conv.api_messages.push(std::sync::Arc::new(
4034                    serde_json::json!({"role": "user", "content": queued}),
4035                ));
4036                self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
4037            }
4038            After::AutoTriggerEvents => {
4039                if self.config.auto_compact {
4040                    self.post_turn_chat().await;
4041                }
4042                if self.compact.is_some() {
4043                    return;
4044                }
4045                // Central claim gate: allows turns while under the configured
4046                // cap (events.auto_turn_cap; 0 = unlimited), denies once reached.
4047                let auto_turn_cap = self.host.config().events.auto_turn_cap;
4048                if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
4049                    self.start_turn(TurnTrigger::EventAuto, None).await;
4050                } else {
4051                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
4052                    self.announce_idle().await;
4053                }
4054            }
4055        }
4056
4057        // P5: observe_terminal — set driver outcome or revoke.
4058        if let Some(terminal) = terminal {
4059            let revoke_reason = if let Some(driver) = self.driver.as_mut() {
4060                super::driver::observe_terminal(driver, &self.runtime, terminal)
4061            } else {
4062                None
4063            };
4064            // Emit DriverTurnOutcome if an outcome was just set.
4065            if let Some(driver) = self.driver.as_ref() {
4066                if let Some((outcome, _)) = &driver.outcome {
4067                    self.emit(SessionEventWire::DriverTurnOutcome {
4068                        outcome: *outcome,
4069                        selection: driver.selection.clone(),
4070                        feedback: driver.grant.feedback_enabled().then(|| {
4071                            driver.completed_feedback.to_string()
4072                        }),
4073                    });
4074                }
4075            }
4076            if let Some(reason) = revoke_reason {
4077                self.driver_revoke(&reason);
4078            }
4079        }
4080    }
4081
4082    /// chat.rs post-turn block: save + engine-budget auto-compaction.
4083    async fn post_turn_chat(&mut self) {
4084        self.request_save();
4085        let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
4086        if assessment.should_compact() {
4087            self.emit(SessionEventWire::SystemNotice(format!(
4088                "[auto-compacting ~{} tokens...]",
4089                assessment.used_tokens()
4090            )));
4091            self.compact(None, "auto").await;
4092        }
4093    }
4094
4095    /// `SYNAPS_SESSION_COMPACT_INLINE=1`: one-release kill-switch back to the
4096    /// #107 inline body (deleted in phase 4).
4097    fn compact_inline_env() -> bool {
4098        matches!(
4099            std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
4100            Ok("1") | Ok("true")
4101        )
4102    }
4103
4104    /// `/compact` + auto-compaction entry (B2): spawns `compact_conversation`
4105    /// on a `Runtime::clone()` (the same clone the TUI makes at
4106    /// dispatch.rs:450 — the clone never runs turns) and returns; the
4107    /// `select!` arm `on_compaction_done` applies the outcome. While a job
4108    /// is in flight Attach/Detach/Cancel/Query are serviced, `Submit` is
4109    /// queued (`Steered{delivered:false}`), auto-turns see `busy`.
4110    pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
4111        // Compaction replaces the history: the old cached prefix is dead.
4112        self.cache_keepwarm_compaction();
4113        if Self::compact_inline_env() {
4114            return self.compact_inline(instructions, source).await;
4115        }
4116        if self.compact.is_some() {
4117            self.emit(SessionEventWire::SystemNotice(
4118                "compaction already in progress".into(),
4119            ));
4120            return;
4121        }
4122        if self.streaming {
4123            self.emit(SessionEventWire::SystemNotice(
4124                "cannot compact while a turn is running".into(),
4125            ));
4126            return;
4127        }
4128        let disclosure =
4129            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
4130        self.emit(SessionEventWire::CompactionStarted {
4131            source: source.to_string(),
4132            disclosure,
4133        });
4134        let msgs = self.conv.api_messages.clone();
4135        let rt: Runtime = (*self.runtime).clone();
4136        let task = tokio::spawn(async move {
4137            compact_conversation(&msgs, &rt, instructions.as_deref()).await
4138        });
4139        self.compact = Some(CompactionJob {
4140            task,
4141            source: source.to_string(),
4142            started: tokio::time::Instant::now(),
4143        });
4144        self.update_attach_state();
4145    }
4146
4147    /// `select!` arm: the spawned job finished (or was aborted). Applies via
4148    /// the ONE engine transition with the configured policy; pending events
4149    /// and the queued message ride the transition (loop_arms.rs:761-836).
4150    async fn on_compaction_done(
4151        &mut self,
4152        res: std::result::Result<
4153            Result<crate::runtime::compaction::CompactionOutcome>,
4154            tokio::task::JoinError,
4155        >,
4156    ) {
4157        let Some(job) = self.compact.take() else {
4158            return;
4159        };
4160        let msg_count = self.conv.api_messages.len();
4161        match res {
4162            Err(join) => {
4163                if !join.is_cancelled() {
4164                    self.emit(SessionEventWire::CompactionFailed {
4165                        message: join.to_string(),
4166                        panicked: true,
4167                    });
4168                }
4169            }
4170            Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
4171                message: e.to_string(),
4172                panicked: false,
4173            }),
4174            Ok(Ok(outcome)) => {
4175                // The transition rewrites the predecessor on disk (its
4176                // `compacted_into` link): a queued save of it must land
4177                // first, never after.
4178                if !self.persister.flush().await {
4179                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4180                }
4181                let policy: CompactionPolicy = self.config.compaction_policy.into();
4182                let queued = self.conv.queued_message.clone();
4183                let applied = apply_compaction(
4184                    &self.runtime,
4185                    &self.conv.session,
4186                    &self.conv.api_messages,
4187                    &outcome,
4188                    CompactionTransition {
4189                        policy,
4190                        pending_events: self.conv.pending_events.clone(),
4191                        queued_message: queued.clone(),
4192                        hook_source: job.source.clone(),
4193                    },
4194                )
4195                .await;
4196                match applied {
4197                    Ok(applied) => {
4198                        let previous = applied.previous_session_id.clone();
4199                        self.conv.session = applied.session;
4200                        self.conv.api_messages = applied.api_messages;
4201                        self.conv.pending_events.clear();
4202                        self.conv.queued_message = None;
4203                        if policy == CompactionPolicy::LinkedSuccessor {
4204                            self.conv.total_input_tokens = 0;
4205                            self.conv.total_output_tokens = 0;
4206                            self.conv.session_cost = 0.0;
4207                            // The keep-warm subtotal follows the totals it
4208                            // is part of: input/output/cost restart, the
4209                            // cache totals (not reset here) keep their
4210                            // keep-warm share.
4211                            self.conv.keepwarm_cost = 0.0;
4212                            self.conv.keepwarm_tokens.input = 0;
4213                            self.conv.keepwarm_tokens.output = 0;
4214                        }
4215                        let new_id = self.conv.session.id.clone();
4216                        self.runtime.set_session_id(Some(new_id.clone()));
4217                        self.set_journal_id(new_id.clone());
4218                        // F10: lock follows the new journal id.
4219                        self.reacquire_session_lock(&new_id);
4220                        // Wall 1: reset continuation state for the new session id
4221                        // so stale epoch checks in persist_head() don't reject
4222                        // future checkpoints.
4223                        self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
4224                        self.runtime
4225                            .reset_context_continuation(&new_id, &self.conv.api_messages);
4226                        self.emit(SessionEventWire::CompactionApplied {
4227                            previous_session_id: previous,
4228                            session_id: new_id,
4229                            chains_advanced: applied.chains_advanced,
4230                            queued_restored: queued,
4231                            msg_count,
4232                        });
4233                    }
4234                    Err(e) => self.emit(SessionEventWire::CompactionFailed {
4235                        message: e.to_string(),
4236                        panicked: false,
4237                    }),
4238                }
4239            }
4240        }
4241        self.emit_conversation();
4242        self.update_attach_state();
4243        self.announce_idle().await;
4244    }
4245
4246    /// #107 inline body (kill-switch only).
4247    async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
4248        self.compacting_inline = true;
4249        self.publish_presence();
4250        self.compact_inline_inner(instructions, source).await;
4251        self.compacting_inline = false;
4252        self.publish_presence();
4253    }
4254
4255    async fn compact_inline_inner(&mut self, instructions: Option<String>, source: &str) {
4256        self.emit(SessionEventWire::SystemNotice(format!(
4257            "[{}]",
4258            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
4259        )));
4260        let outcome =
4261            compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
4262                .await;
4263        let applied = match outcome {
4264            Ok(outcome) => {
4265                // Queued saves land before the transition rewrites the file.
4266                if !self.persister.flush().await {
4267                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4268                }
4269                apply_compaction(
4270                    &self.runtime,
4271                    &self.conv.session,
4272                    &self.conv.api_messages,
4273                    &outcome,
4274                    CompactionTransition {
4275                        policy: CompactionPolicy::InPlace,
4276                        pending_events: Vec::new(),
4277                        queued_message: None,
4278                        hook_source: source.to_string(),
4279                    },
4280                )
4281                .await
4282            }
4283            Err(e) => Err(e),
4284        };
4285        match applied {
4286            Ok(applied) => {
4287                self.conv.session = applied.session;
4288                self.conv.api_messages = applied.api_messages;
4289                let after = self.runtime.assess_context(&self.conv.api_messages).await;
4290                self.emit(SessionEventWire::SystemNotice(format!(
4291                    "[compacted → ~{} tokens]",
4292                    after.used_tokens()
4293                )));
4294            }
4295            Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
4296                "[compaction failed: {}]",
4297                e
4298            ))),
4299        }
4300        self.emit_conversation();
4301    }
4302
4303    // ── prompts ──────────────────────────────────────────────────────────
4304
4305    fn on_prompt_request(&mut self, req: SecretPromptRequest) {
4306        let id = self.next_prompt_id;
4307        self.next_prompt_id += 1;
4308        let pr = PromptRequest {
4309            id,
4310            kind: PromptKind::from_title(&req.title),
4311            title: req.title,
4312            prompt: req.prompt,
4313            raised_at: chrono::Utc::now(),
4314            confirm_scope: req.confirm_scope,
4315        };
4316        self.pending_prompts.push_back((pr.clone(), req.response_tx));
4317        self.publish_presence();
4318        self.emit(SessionEventWire::Prompt(pr));
4319        // A prompt can be raised while already detached (a turn running with no
4320        // client). Arm the abandonment deadline if this left us pinned.
4321        self.rearm_prompt_abandon();
4322    }
4323
4324    /// Why `from` may not give this answer to `prompt`, if it may not.
4325    /// Confirm prompts gate tool execution, so an `Observe` client cannot
4326    /// answer them at all, and only the input owner (or the host, `from:
4327    /// None`) can choose "Allow all", which arms a session-wide latch.
4328    fn confirm_answer_refusal(
4329        &self,
4330        from: Option<ClientId>,
4331        prompt: &PromptRequest,
4332        allow_all: bool,
4333    ) -> Option<&'static str> {
4334        let c = from?;
4335        if prompt.kind != PromptKind::Confirm {
4336            return None;
4337        }
4338        if self.attached.get(&c).map(|a| a.mode) == Some(AttachMode::Observe) {
4339            return Some("observe-mode clients cannot answer confirm prompts");
4340        }
4341        if allow_all && self.input_owner != Some(c) {
4342            return Some("only the input owner can choose Allow all");
4343        }
4344        None
4345    }
4346
4347    fn answer(&mut self, from: Option<ClientId>, prompt_id: u64, value: Option<String>) {
4348        let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
4349            return; // unknown or already answered — dedup on id
4350        };
4351        let allow_all = value.as_deref().is_some_and(|v| {
4352            v.trim()
4353                .eq_ignore_ascii_case(crate::tools::CONFIRM_ANSWER_ALLOW_ALL)
4354        });
4355        // An answer is input too: with `client_input = shared` the answering
4356        // drive client becomes active first, so "Allow all" (owner only)
4357        // is its to choose.
4358        if let Some(c) = from {
4359            self.note_input(c);
4360        }
4361        if let Some(reason) = self.confirm_answer_refusal(from, &self.pending_prompts[pos].0, allow_all) {
4362            if let Some(c) = from {
4363                self.emit(SessionEventWire::Refused {
4364                    client: c,
4365                    command: "answer".to_string(),
4366                    reason: reason.to_string(),
4367                });
4368            }
4369            return; // the prompt stays pending for an eligible client
4370        }
4371        let approved = allow_all
4372            || value
4373                .as_deref()
4374                .map(str::trim)
4375                .is_some_and(|v| v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes"));
4376        let (prompt, tx) = self.pending_prompts.remove(pos).expect("position");
4377        let _ = tx.send(value);
4378        self.publish_presence();
4379        self.emit(SessionEventWire::PromptResolved { prompt_id });
4380        let by = match from {
4381            Some(c) => self.client_label(c),
4382            None => "the host".to_string(),
4383        };
4384        // prA N3: the tool card shows the model's original input; when the
4385        // approved call runs input rewritten by an extension, every client's
4386        // transcript records that (the prompt showed the final input).
4387        if approved && prompt.title == CONFIRM_PROMPT_TITLE {
4388            if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.rewritten_by.is_empty()) {
4389                self.emit(SessionEventWire::SystemNotice(format!(
4390                    "Approved by {by}: {} runs with input rewritten by {} (not the input shown \
4391                     on its tool card; the approval prompt showed what runs).",
4392                    scope.tool.as_deref().unwrap_or("the tool call"),
4393                    scope.rewritten_by.join(", ")
4394                )));
4395            }
4396        }
4397        // Arming the latch is announced to every attached client, not just
4398        // the one that pressed the key.
4399        if allow_all && prompt.title == CONFIRM_PROMPT_TITLE {
4400            if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.extensions.is_empty()) {
4401                self.emit(SessionEventWire::SystemNotice(format!(
4402                    "Allow all armed by {by}: tool-call confirms from {} are auto-approved \
4403                     until /new. Other extensions still ask. /confirms reset revokes.",
4404                    scope.extensions_label()
4405                )));
4406            }
4407        }
4408        // The last pending prompt may have just cleared — drop the deadline.
4409        self.rearm_prompt_abandon();
4410    }
4411
4412    // ── settings / queries / engine commands ─────────────────────────────
4413
4414    pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
4415        if replayable(&setting) {
4416            let disc = std::mem::discriminant(&setting);
4417            self.settings_replay
4418                .retain(|s| std::mem::discriminant(s) != disc);
4419            self.settings_replay.push(setting.clone());
4420        }
4421        let name = match &setting {
4422            SessionSetting::Model { .. } => "model",
4423            SessionSetting::ReasoningLevel { .. } => "reasoning_level",
4424            SessionSetting::ContextWindow { .. } => "context_window",
4425            SessionSetting::CompactionModel { .. } => "compaction_model",
4426            SessionSetting::ApiRetries { .. } => "api_retries",
4427            SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
4428            SessionSetting::MaxToolOutput { .. } => "max_tool_output",
4429            SessionSetting::BashTimeout { .. } => "bash_timeout",
4430            SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
4431            SessionSetting::SystemPrompt { .. } => "system_prompt",
4432            SessionSetting::ReloadPrompt => "reload_prompt",
4433            SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
4434        };
4435        // Capability evidence must exist in THIS (possibly daemon) process
4436        // before the mutation is validated and a reasoning clamp decided.
4437        match &setting {
4438            SessionSetting::Model { model } => self.runtime.prepare_model_capabilities(model).await,
4439            SessionSetting::ReasoningLevel { .. } => {
4440                let model = self.runtime.model().to_string();
4441                self.runtime.prepare_model_capabilities(&model).await;
4442            }
4443            _ => {}
4444        }
4445        let rt = &mut self.runtime;
4446        let mut clamp_wire = None;
4447        let result: std::result::Result<Option<String>, String> = match setting {
4448            SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
4449                self.conv.session.model = rt.model().to_string();
4450                clamp.map(|c| {
4451                    self.conv.session.thinking_level = rt.thinking_level().to_string();
4452                    clamp_wire = Some(ReasoningClampWire {
4453                        from: c.from.as_str().to_string(),
4454                        to: c.to.as_str().to_string(),
4455                    });
4456                    format!(
4457                        "thinking → {} (clamped from {}: not supported by {})",
4458                        c.to.as_str(),
4459                        c.from.as_str(),
4460                        rt.model()
4461                    )
4462                })
4463            }),
4464            SessionSetting::ReasoningLevel { level } => {
4465                rt.set_reasoning_level_checked(level).map(|_| {
4466                    self.conv.session.thinking_level = rt.thinking_level().to_string();
4467                    None
4468                })
4469            }
4470            SessionSetting::ContextWindow { tokens } => {
4471                rt.set_context_window(tokens);
4472                Ok(None)
4473            }
4474            SessionSetting::CompactionModel { model } => {
4475                rt.set_compaction_model(model);
4476                Ok(None)
4477            }
4478            SessionSetting::ApiRetries { n } => {
4479                rt.set_api_retries(n);
4480                Ok(None)
4481            }
4482            SessionSetting::SubagentTimeout { secs } => {
4483                rt.set_subagent_timeout(secs);
4484                Ok(None)
4485            }
4486            SessionSetting::MaxToolOutput { bytes } => {
4487                rt.set_max_tool_output(bytes);
4488                Ok(None)
4489            }
4490            SessionSetting::BashTimeout { secs } => {
4491                rt.set_bash_timeout(secs);
4492                Ok(None)
4493            }
4494            SessionSetting::BashMaxTimeout { secs } => {
4495                rt.set_bash_max_timeout(secs);
4496                Ok(None)
4497            }
4498            SessionSetting::SystemPrompt { text } => {
4499                rt.set_system_prompt(text);
4500                Ok(None)
4501            }
4502            SessionSetting::ReloadPrompt => rt
4503                .reload_prompt()
4504                .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
4505                .map_err(|e| e.to_string()),
4506            SessionSetting::GrantWorkerModel { model } => {
4507                rt.grant_worker_model(&model).map(|_| None)
4508            }
4509        };
4510        self.publish_view().await;
4511        let view = (**self.view.load()).clone();
4512        let (ok, message) = match result {
4513            Ok(m) => (true, m),
4514            Err(e) => (false, Some(e)),
4515        };
4516        self.emit(SessionEventWire::SettingChanged(SettingApplied {
4517            id,
4518            setting: name.to_string(),
4519            ok,
4520            message,
4521            view,
4522            clamp: clamp_wire,
4523        }));
4524    }
4525
4526    pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
4527        // Status/View never wake a parked session (the idle probe must not).
4528        if self.is_parked() {
4529            let value = match query {
4530                SessionQuery::Status => serde_json::json!({
4531                    "session": (**self.journal_id.load()).clone(),
4532                    "model": self.view.load().model,
4533                    "lifecycle": SessionLifecycle::Parked,
4534                    "streaming": false,
4535                    "auto_turns": self.consecutive_auto_turns,
4536                    "attached": 0,
4537                    "pending_prompts": 0,
4538                    "pinned": self.keep_warm,
4539                }),
4540                SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4541                // A parked session has no live workers; a watch poll must not wake it.
4542                SessionQuery::SubagentWatch { handle_id, .. } => {
4543                    serde_json::json!({ "error": format!("no subagent {handle_id} in this session") })
4544                }
4545                _ => match self.ensure_live().await {
4546                    Ok(()) => return Box::pin(self.query(id, query)).await,
4547                    Err(e) => serde_json::json!({ "error": e }),
4548                },
4549            };
4550            self.emit(SessionEventWire::QueryResult { id, value });
4551            return;
4552        }
4553        let value = match query {
4554            SessionQuery::Status => serde_json::json!({
4555                "session": self.conv.session.id,
4556                "model": self.runtime.model(),
4557                "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
4558                "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
4559                "cost": self.conv.session_cost,
4560                // Included in `cost`; labelled separately (task #444).
4561                "keepwarm_cost": self.conv.keepwarm_cost,
4562                "keepwarm": self.cache_keepwarm_status_json(None),
4563                "messages": self.conv.api_messages.len(),
4564                "streaming": self.streaming,
4565                "auto_turns": self.consecutive_auto_turns,
4566                "attached": self.attached.len(),
4567                "pending_prompts": self.pending_prompts.len(),
4568                // `/pin` status in the TUI.
4569                "pinned": self.keep_warm,
4570            }),
4571            SessionQuery::Messages => {
4572                serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
4573            }
4574            SessionQuery::DisplayTail { items } => {
4575                serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
4576                    .unwrap_or_default()
4577            }
4578            SessionQuery::SubagentRows => {
4579                let rows = self
4580                    .runtime
4581                    .subagent_registry()
4582                    .lock()
4583                    .unwrap_or_else(|p| p.into_inner())
4584                    .display_rows();
4585                serde_json::Value::Array(
4586                    rows.iter()
4587                        .map(|r| {
4588                            serde_json::json!({
4589                                "subagent_id": r.subagent_id,
4590                                "agent_name": r.agent_name,
4591                                "status": format!("{:?}", r.status),
4592                                "cancel_requested": r.cancel_requested,
4593                                "elapsed_secs": r.elapsed_secs,
4594                            })
4595                        })
4596                        .collect(),
4597                )
4598            }
4599            SessionQuery::SubagentWatch { handle_id, since, tool_queue } => {
4600                let mut registry = self
4601                    .runtime
4602                    .subagent_registry()
4603                    .lock()
4604                    .unwrap_or_else(|p| p.into_inner());
4605                match registry.watch_page(&handle_id, since) {
4606                    // An older client did not ask for queue entries and
4607                    // could not decode a page that had them.
4608                    Some(page) if !tool_queue => {
4609                        serde_json::to_value(page.without_tool_queue()).unwrap_or_default()
4610                    }
4611                    Some(page) => serde_json::to_value(page).unwrap_or_default(),
4612                    None => serde_json::json!({
4613                        "error": format!("no subagent {handle_id} in this session")
4614                    }),
4615                }
4616            }
4617            SessionQuery::PromptInspection => {
4618                serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
4619            }
4620            SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
4621            SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4622            SessionQuery::ContextAssessment => {
4623                let a = self.runtime.assess_context(&self.conv.api_messages).await;
4624                serde_json::json!({
4625                    "used_tokens": a.used_tokens(),
4626                    "budget_tokens": a.budget_tokens(),
4627                    "provider_window": a.provider_window,
4628                    "should_compact": a.should_compact(),
4629                })
4630            }
4631            SessionQuery::ContextReport => {
4632                use crate::engine::commands::{context_command, CommandResult};
4633                match context_command(&self.runtime, Some(&self.conv.api_messages)) {
4634                    CommandResult::Output(text) => serde_json::json!({ "text": text }),
4635                    other => serde_json::json!({ "unsupported": format!("{other:?}") }),
4636                }
4637            }
4638        };
4639        self.emit(SessionEventWire::QueryResult { id, value });
4640    }
4641
4642    // ── attach / detach ──────────────────────────────────────────────────
4643
4644    pub(crate) fn snapshot(&self) -> AttachSnapshot {
4645        AttachSnapshot {
4646            meta: self.meta.clone(),
4647            view: (**self.view.load()).clone(),
4648            conversation: self.conv.snapshot(self.consecutive_auto_turns),
4649            streaming: self.streaming,
4650            replay: self.turn_replay.iter().cloned().collect(),
4651            pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
4652            clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
4653            roster: self.roster(),
4654            client_input: self.client_input,
4655            input_owner: self.input_owner,
4656            display_tail: None,
4657        }
4658    }
4659
4660    /// `snapshot()` shaped for one client (phase 4 §2.3): `Digest` clients
4661    /// get an empty `api_messages` (`messages_len` kept), a daemon-projected
4662    /// `display_tail`, and a replay without the per-round `MessageHistory`
4663    /// envelopes (the trailing `Conversation` digest carries len/hash).
4664    pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
4665        let mut snap = self.snapshot();
4666        if client.history == HistoryMode::Digest {
4667            snap.display_tail = Some(super::display::display_tail(
4668                &snap.conversation.api_messages,
4669                client.tail_items,
4670            ));
4671            snap.conversation.api_messages = Vec::new();
4672            snap.replay.retain(|e| {
4673                !matches!(
4674                    e.event,
4675                    SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
4676                )
4677            });
4678        }
4679        snap
4680    }
4681
4682    /// B1 ownership: `Observe` never owns; `Mirror` owns iff nobody does
4683    /// (else read-only + notice); `Takeover` steals with
4684    /// `InputOwnerChanged{reason: Takeover}` to everyone (the old owner's
4685    /// client renders the toast).
4686    async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
4687        if let Err(e) = self.ensure_live().await {
4688            self.emit(SessionEventWire::AttachRefused { message: e });
4689            return;
4690        }
4691        let cid = ClientId(self.next_client_id);
4692        self.next_client_id += 1;
4693        let kind = client.kind;
4694        self.cache_keepwarm_note_attach(kind, mode);
4695        let snapshot_meta = client.clone();
4696        let name = resolve_client_name(
4697            client.name.as_deref(),
4698            kind,
4699            self.attached.values().map(|a| a.name.as_str()),
4700        );
4701        let attached_at_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
4702        self.attached.insert(
4703            cid,
4704            AttachedClient { meta: client, mode, name: name.clone(), attached_at_ms, last_input: 0 },
4705        );
4706        self.update_attach_state();
4707        let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
4708        match mode {
4709            AttachMode::Observe => {}
4710            // Input owned elsewhere: the joiner learns it from its snapshot
4711            // (`AttachSnapshot::input_owned_elsewhere`), never from a notice
4712            // broadcast to every client.
4713            AttachMode::Mirror => {
4714                if self.input_owner.is_none() {
4715                    owner_change = Some((None, OwnerChangeReason::Attach));
4716                }
4717            }
4718            AttachMode::Takeover => {
4719                let reason = if self.input_owner.is_some() {
4720                    OwnerChangeReason::Takeover
4721                } else {
4722                    OwnerChangeReason::Attach
4723                };
4724                owner_change = Some((self.input_owner, reason));
4725            }
4726        }
4727        if let Some((from, reason)) = owner_change {
4728            self.input_owner = Some(cid);
4729            self.emit(SessionEventWire::InputOwnerChanged {
4730                from,
4731                to: Some(cid),
4732                reason,
4733            });
4734        }
4735        self.publish_presence();
4736        let snapshot = self.snapshot_for(&snapshot_meta);
4737        self.emit(SessionEventWire::Attached {
4738            client: cid,
4739            snapshot,
4740        });
4741        self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode });
4742    }
4743
4744    /// B1 `/takeover`: the sending client takes input in place, as an
4745    /// `AttachMode::Takeover` attach would, without re-attaching. An
4746    /// `Observe` client is refused (it attached read-only on purpose); the
4747    /// owner, the host and unknown clients change nothing. Any turn keeps
4748    /// running: only who may drive the session changes.
4749    fn take_input(&mut self, from: Option<ClientId>) {
4750        let Some(c) = from else { return };
4751        let Some(mode) = self.attached.get(&c).map(|a| a.mode) else { return };
4752        if mode == AttachMode::Observe {
4753            self.emit(SessionEventWire::Refused {
4754                client: c,
4755                command: command_name(&SessionCommand::TakeInput).to_string(),
4756                reason: "this client attached with --observe and never owns input; \
4757                         attach without --observe to take it"
4758                    .to_string(),
4759            });
4760            return;
4761        }
4762        if self.input_owner == Some(c) {
4763            return;
4764        }
4765        let from = self.input_owner;
4766        let reason = if from.is_some() {
4767            OwnerChangeReason::Takeover
4768        } else {
4769            OwnerChangeReason::Attach
4770        };
4771        self.input_owner = Some(c);
4772        self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason });
4773        self.publish_presence();
4774        self.note_input(c); // most recent, for promotion on detach
4775    }
4776
4777    /// Never touches `stream`/`cancel`: the turn keeps running and its
4778    /// events buffer in the broadcast (§8 detach-without-abort). The owner
4779    /// leaving passes input to the oldest attached non-`Observe` client.
4780    fn detach(&mut self, client: ClientId) {
4781        if self.attached.remove(&client).is_none() {
4782            return;
4783        }
4784        self.update_attach_state();
4785        self.emit(SessionEventWire::ClientLeft { client });
4786        if self.input_owner == Some(client) {
4787            // `shared`: back to whoever typed last (a script that sent one
4788            // prompt and left hands input back to the tab in use);
4789            // `exclusive`: the lowest id, as before. Ties: the lowest id.
4790            let shared = self.client_input == ClientInput::Shared;
4791            let next = self
4792                .attached
4793                .iter()
4794                .filter(|(_, a)| a.mode != AttachMode::Observe)
4795                .max_by_key(|(c, a)| (if shared { a.last_input } else { 0 }, std::cmp::Reverse(c.0)))
4796                .map(|(c, _)| *c);
4797            self.input_owner = next;
4798            self.emit(SessionEventWire::InputOwnerChanged {
4799                from: Some(client),
4800                to: next,
4801                reason: OwnerChangeReason::OwnerDetached,
4802            });
4803            self.publish_presence();
4804        }
4805        // (E-P7 §S1 / P11) The last client just left. Fail-closed gates apply
4806        // ONLY when a driver is (or was about to be) armed:
4807        //
4808        //  1. An armed driver is revoked: it must never run turns headless with
4809        //     nobody watching and no way to answer a confirmation.
4810        //  2. Its pending host confirmations are answered `None` (deny) — a
4811        //     headless autonomous run must not sit on an unanswerable prompt.
4812        //     `tools/discovery.rs` treats `None` as Unauthorized (deny).
4813        //
4814        // For a PLAIN interactive session (no driver), a pending prompt SURVIVES
4815        // detach: the user reattaches and answers it — the daemon's core
4816        // detach/reattach contract, and detach is often involuntary (SSH drop,
4817        // sleep, wifi). Denying on detach would let a transient disconnect
4818        // silently reject the user's action. The resource pin from an
4819        // *abandoned* prompt is bounded by the pending-prompt deadline instead
4820        // (see the actor select loop), not by punishing every detach.
4821        if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
4822            self.driver_revoke("no clients attached");
4823            let had_prompts = !self.pending_prompts.is_empty();
4824            while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4825                let _ = tx.send(None);
4826                self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4827            }
4828            if had_prompts {
4829                self.publish_presence();
4830            }
4831        }
4832    }
4833
4834    /// B1 (used by C3 reload): checkpoint the session without ending it —
4835    /// cancel any turn (partial history kept + interruption marker), abort compaction, answer
4836    /// pending prompts `None`, save, close PTYs. Replies on
4837    /// `CHECKPOINT_QUERY_ID` so `reload.rs` can await it per session.
4838    pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
4839        let interrupt = match reason {
4840            CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
4841            CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
4842        };
4843        // Before the revoke below cancels a driver turn.
4844        self.note_cancel_cause(interrupt);
4845        // E-P3: driver does NOT survive reload (§3 S2/S5).
4846        if self.driver.is_some() {
4847            self.driver_revoke("daemon reloaded");
4848        }
4849        // Workers cannot survive the exec either (they are threads of this
4850        // process). Flag + cancel them BEFORE `cancel_turn` (whose own
4851        // cancel would otherwise label them user-cancelled). This also
4852        // fences new launches: a worker the turn registers before its
4853        // cancel lands is stopped at birth as the reload's.
4854        let reload = matches!(reason, CheckpointReason::Reload);
4855        if reload {
4856            self.stop_workers_for_reload();
4857        }
4858        if self.streaming {
4859            self.cancel_turn(interrupt).await;
4860        }
4861        // Again after the turn cancel: every worker to await, including
4862        // any the cancelled turn registered meanwhile.
4863        let stopping = if reload { self.stop_workers_for_reload() } else { Vec::new() };
4864        let stopped_workers = await_stopped_workers(self.id.as_str(), stopping).await;
4865        self.abort_compaction();
4866        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4867            let _ = tx.send(None);
4868            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4869        }
4870        // One bounded wait covers the cancelled turn's save too (queued, not
4871        // awaited, by `cancel_turn`).
4872        if !self.save().await {
4873            tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
4874        }
4875        let notice = match reason {
4876            CheckpointReason::Reload => {
4877                "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
4878            }
4879            CheckpointReason::HostRequest => {
4880                "checkpoint — background shells/PTYs closed; the turn was checkpointed"
4881            }
4882        };
4883        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
4884        if !stopped_workers.is_empty() {
4885            self.emit(SessionEventWire::SystemNotice(format!(
4886                "daemon reloading — stopped worker{} {} (archived; resumable after the reload)",
4887                if stopped_workers.len() == 1 { "" } else { "s" },
4888                stopped_workers.join(", ")
4889            )));
4890        }
4891        self.runtime.session_manager().shutdown_all();
4892        let mut record = self.reload_record();
4893        if reload {
4894            record.workers = self.reload_carried_workers();
4895            // Recorded: from here to the exec, nothing may start work the
4896            // record does not carry (a queued wake must stay queued).
4897            self.reload_fence = Some(ReloadFence {
4898                deadline: tokio::time::Instant::now() + budgets::RELOAD_FENCE_TIMEOUT,
4899                workers: record.workers.clone(),
4900            });
4901        }
4902        let record = serde_json::to_value(record).unwrap_or_default();
4903        self.emit(SessionEventWire::QueryResult {
4904            id: super::wire::CHECKPOINT_QUERY_ID,
4905            value: serde_json::json!({ "ok": true, "record": record }),
4906        });
4907    }
4908
4909    /// The workers a reload carries for this session: every registry entry,
4910    /// plus whether a finished worker's completion wake is still queued
4911    /// (the exec discards the queue, so the next image re-posts it).
4912    pub(crate) fn reload_carried_workers(&self) -> Vec<crate::runtime::subagent::CarriedWorker> {
4913        let undelivered: std::collections::HashSet<String> = self
4914            .event_queue
4915            .snapshot()
4916            .iter()
4917            .filter(|e| e.content.content_type == "subagent_completion")
4918            .filter_map(|e| {
4919                e.content
4920                    .data
4921                    .as_ref()?
4922                    .get("handle_id")?
4923                    .as_str()
4924                    .map(str::to_string)
4925            })
4926            .collect();
4927        self.runtime
4928            .subagent_registry()
4929            .lock()
4930            .unwrap_or_else(|p| p.into_inner())
4931            .reload_carry(&undelivered)
4932    }
4933
4934    /// Host-only (`SessionCommand::RestoreToolActivations`, sent by
4935    /// reload's rehydrate): the activations this conversation held in the
4936    /// previous image. A live runtime takes them (restored at its first turn
4937    /// start); a parked session keeps them for the runtime its unpark
4938    /// builds, without unparking.
4939    pub(crate) fn restore_tool_activations(
4940        &mut self,
4941        activations: Vec<crate::tools::activation::CarriedActivation>,
4942    ) {
4943        if activations.is_empty() {
4944            return;
4945        }
4946        tracing::info!(
4947            session = %self.id,
4948            count = activations.len(),
4949            "reload: tool activations handed over from the previous image"
4950        );
4951        if self.runtime.is_live() {
4952            self.runtime.restore_tool_activations(activations);
4953        } else {
4954            for a in activations {
4955                if !self.parked_tool_activations.iter().any(|p| p.tool_id == a.tool_id) {
4956                    self.parked_tool_activations.push(a);
4957                }
4958            }
4959        }
4960    }
4961
4962    /// Host-only (`SessionCommand::AdoptWorkers`, sent by reload's
4963    /// rehydrate): the workers the previous daemon image stopped or still
4964    /// owed a wake. Their ids resolve to their archives again, and the
4965    /// parent is woken once per worker — it was waiting for completions the
4966    /// exec destroyed.
4967    pub(crate) fn adopt_workers(&mut self, workers: Vec<crate::runtime::subagent::CarriedWorker>) {
4968        self.adopt_carried_workers(workers, false);
4969    }
4970
4971    /// [`Self::adopt_workers`]; `failed_reload`: this image's own exec
4972    /// failed (`ReloadAborted`), so the notice must not say it reloaded.
4973    fn adopt_carried_workers(
4974        &mut self,
4975        workers: Vec<crate::runtime::subagent::CarriedWorker>,
4976        failed_reload: bool,
4977    ) {
4978        if workers.is_empty() {
4979            return;
4980        }
4981        {
4982            let mut registry = self
4983                .runtime
4984                .subagent_registry()
4985                .lock()
4986                .unwrap_or_else(|p| p.into_inner());
4987            for w in &workers {
4988                registry.adopt_carried(w);
4989                if w.needs_wake() {
4990                    // The next reload repeats this wake only while it is
4991                    // still queued.
4992                    registry.note_reload_wake(w);
4993                }
4994            }
4995        }
4996        let woken: Vec<&crate::runtime::subagent::CarriedWorker> =
4997            workers.iter().filter(|w| w.needs_wake()).collect();
4998        for w in &woken {
4999            let ev = crate::tools::build_reload_carry_event(w);
5000            if self.event_queue.push(ev.clone()).is_err() {
5001                self.event_queue.push_priority(ev);
5002            }
5003        }
5004        let stopped: Vec<&str> = woken
5005            .iter()
5006            .filter(|w| w.stopped_by_reload)
5007            .map(|w| w.id.as_str())
5008            .collect();
5009        tracing::info!(
5010            session = %self.id,
5011            carried = workers.len(),
5012            stopped = ?stopped,
5013            woken = woken.len(),
5014            "reload: adopted workers from the previous image"
5015        );
5016        if !stopped.is_empty() {
5017            self.emit(SessionEventWire::SystemNotice(format!(
5018                "{} — worker{} {} stopped by the reload; subagent_resume continues {} from {} archive",
5019                if failed_reload { "daemon reload failed" } else { "daemon reloaded" },
5020                if stopped.len() == 1 { "" } else { "s" },
5021                stopped.join(", "),
5022                if stopped.len() == 1 { "it" } else { "them" },
5023                if stopped.len() == 1 { "its" } else { "their" },
5024            )));
5025        }
5026    }
5027
5028    /// `stop_for_reload` on this session's registry (fences new launches).
5029    fn stop_workers_for_reload(
5030        &self,
5031    ) -> Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)> {
5032        self.runtime
5033            .subagent_registry()
5034            .lock()
5035            .unwrap_or_else(|p| p.into_inner())
5036            .stop_for_reload()
5037    }
5038
5039    /// Host-only (`SessionCommand::ReloadAborted`): the reload this session
5040    /// checkpointed for did not exec, so the old image keeps serving it.
5041    /// Lift the reload fence, hand the workers the checkpoint stopped back
5042    /// to the parent, and deliver what queued up meanwhile.
5043    pub(crate) async fn reload_aborted(&mut self) {
5044        self.lift_reload_fence(
5045            "daemon reload failed — this session continues on the running daemon",
5046        )
5047        .await;
5048    }
5049
5050    /// Lift the reload fence (see [`Self::reload_aborted`]), telling the
5051    /// user `notice` (the reload did not happen).
5052    async fn lift_reload_fence(&mut self, notice: &str) {
5053        let Some(fence) = self.reload_fence.take() else {
5054            return;
5055        };
5056        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
5057        let workers = if self.runtime.is_live() {
5058            // From the LIVE registry, not only the checkpoint's snapshot: a
5059            // worker registered after it was stopped at birth by the
5060            // registry's reload fence and is owed its wake too. Snapshot
5061            // entries the registry no longer holds ride along.
5062            let mut carried = self.reload_carried_workers();
5063            carried.extend(
5064                fence
5065                    .workers
5066                    .into_iter()
5067                    .filter(|w| !carried.iter().any(|c| c.id == w.id))
5068                    .collect::<Vec<_>>(),
5069            );
5070            let mut registry = self
5071                .runtime
5072                .subagent_registry()
5073                .lock()
5074                .unwrap_or_else(|p| p.into_inner());
5075            registry.lift_reload_fence();
5076            // The queue the record assumed lost is still here: only the
5077            // reload's own stops owe a wake.
5078            registry.rewake_after_failed_exec(carried)
5079        } else {
5080            Vec::new()
5081        };
5082        tracing::info!(session = %self.id, "reload did not exec: fence lifted");
5083        self.adopt_carried_workers(workers, true);
5084        self.rearm_park();
5085        if !self.event_queue.is_empty() {
5086            self.on_queue_wake().await;
5087        }
5088    }
5089
5090    /// Backstop: no exec and no `ReloadAborted` within
5091    /// [`budgets::RELOAD_FENCE_TIMEOUT`] of the checkpoint.
5092    async fn reload_fence_expired(&mut self) {
5093        tracing::warn!(
5094            session = %self.id,
5095            "reload fence expired with no exec and no abort from the daemon; lifting it"
5096        );
5097        self.lift_reload_fence(
5098            "daemon reload never completed or reported back — this session continues on the running daemon",
5099        )
5100        .await;
5101    }
5102
5103    /// Abort the spawned job (`CompactionCancelled`); prior state intact.
5104    pub(crate) fn abort_compaction(&mut self) {
5105        if let Some(job) = self.compact.take() {
5106            job.task.abort();
5107            self.emit(SessionEventWire::CompactionCancelled);
5108            self.publish_presence();
5109        }
5110    }
5111
5112    // ── command dispatch ─────────────────────────────────────────────────
5113
5114    /// B1: a client-stamped input command from anyone but the owner is
5115    /// `Refused` with no side effect; `from: None` (host) bypasses.
5116    async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
5117        use std::ops::ControlFlow;
5118        let Addressed { from, cmd } = addressed;
5119        if let Some(c) = from {
5120            // Only the daemon's reload sends a reload checkpoint (it stops
5121            // every worker and fences the session for the exec after it).
5122            if matches!(cmd, SessionCommand::Checkpoint { reason: CheckpointReason::Reload }) {
5123                self.emit(SessionEventWire::Refused {
5124                    client: c,
5125                    command: command_name(&cmd).to_string(),
5126                    reason: "checkpoint(reload) is host-only; use `synaps daemon reload`".into(),
5127                });
5128                return ControlFlow::Continue(());
5129            }
5130            if let Some(reason) = is_input_command(&cmd).then(|| self.input_refusal(c)).flatten() {
5131                self.emit(SessionEventWire::Refused {
5132                    client: c,
5133                    command: command_name(&cmd).to_string(),
5134                    reason,
5135                });
5136                return ControlFlow::Continue(());
5137            }
5138        }
5139        // A reload checkpointed this session: nothing may start work or
5140        // change what the reload record captured until the exec (or until
5141        // the daemon reports it failed). The wake sources are fenced in the
5142        // select loop; commands are fenced here.
5143        if self.reload_fence.is_some() && cmd.refused_while_reloading() {
5144            let reason = super::RELOADING_REFUSAL.to_string();
5145            match from {
5146                Some(c) => self.emit(SessionEventWire::Refused {
5147                    client: c,
5148                    command: command_name(&cmd).to_string(),
5149                    reason,
5150                }),
5151                None => self.emit(SessionEventWire::SystemNotice(reason)),
5152            }
5153            return ControlFlow::Continue(());
5154        }
5155        // Accepted input: with `client_input = shared` its sender becomes the
5156        // active client. `KeepWarm` doesn't count: `--keep-warm` sends it
5157        // on attach, not a person typing.
5158        if let Some(c) = from {
5159            if is_input_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { .. }) {
5160                self.note_input(c);
5161            }
5162        }
5163        // B3: anything that needs the runtime wakes a parked session first.
5164        if self.is_parked() {
5165            let needs_runtime = !matches!(
5166                cmd,
5167                SessionCommand::Attach { .. }
5168                    | SessionCommand::Detach { .. }
5169                    | SessionCommand::End { .. }
5170                    | SessionCommand::Query { .. }
5171                    | SessionCommand::Answer { .. }
5172                    | SessionCommand::Save
5173                    | SessionCommand::Cancel
5174                    | SessionCommand::Checkpoint { .. }
5175                    | SessionCommand::Park
5176                    | SessionCommand::ReloadAborted
5177                    | SessionCommand::ArmPark
5178                    | SessionCommand::RestoreToolActivations { .. }
5179                    | SessionCommand::Resync { .. }
5180                    | SessionCommand::HostEvent(_)
5181                    | SessionCommand::TakeInput
5182            ) && !is_close_command(&cmd) // closing a parked session must not rebuild it
5183                // Unpinning a parked session only clears the flag; pinning
5184                // one wakes it (a pinned session is live by definition).
5185                && !matches!(cmd, SessionCommand::KeepWarm { on: false });
5186            if matches!(cmd, SessionCommand::HostEvent(_)) {
5187                return ControlFlow::Continue(()); // nobody attached, nothing to render
5188            }
5189            if matches!(
5190                cmd,
5191                SessionCommand::Save
5192                    | SessionCommand::Cancel
5193                    | SessionCommand::Park
5194                    | SessionCommand::ArmPark
5195                    | SessionCommand::Checkpoint { .. }
5196            ) {
5197                if let SessionCommand::Checkpoint { .. } = cmd {
5198                    let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
5199                    self.emit(SessionEventWire::QueryResult {
5200                        id: super::wire::CHECKPOINT_QUERY_ID,
5201                        value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
5202                    });
5203                }
5204                return ControlFlow::Continue(()); // already on disk / idle
5205            }
5206            if needs_runtime {
5207                if let Err(e) = self.ensure_live().await {
5208                    match from {
5209                        Some(c) => self.emit(SessionEventWire::Refused {
5210                            client: c,
5211                            command: command_name(&cmd).to_string(),
5212                            reason: e,
5213                        }),
5214                        None => self.emit(SessionEventWire::SystemNotice(e)),
5215                    }
5216                    return ControlFlow::Continue(());
5217                }
5218            }
5219        }
5220        match cmd {
5221            SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
5222            SessionCommand::Steer { text } => {
5223                if self.streaming {
5224                    self.steer(text)
5225                } else {
5226                    self.submit(text, vec![], from).await
5227                }
5228            }
5229            SessionCommand::SubagentSteer { handle_id, text } => {
5230                self.steer_subagent(from, &handle_id, text)
5231            }
5232            SessionCommand::Cancel => {
5233                // The user's cancel, even of a driver's turn: noted before
5234                // the revoke below cancels that turn.
5235                self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
5236                if self.driver.is_some() {
5237                    self.driver_revoke("canceled");
5238                }
5239                self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
5240                    .await;
5241            }
5242            SessionCommand::Answer { prompt_id, value } => self.answer(from, prompt_id, value),
5243            SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
5244            // `CompactionStarted` is the contract; no notice before it.
5245            SessionCommand::Compact { instructions } => {
5246                self.compact(instructions, "manual").await;
5247            }
5248            SessionCommand::NewSession => {
5249                if self.driver.is_some() {
5250                    self.driver_revoke("session replaced");
5251                }
5252                // A turn still waiting for `on_session_start` would open its
5253                // stream later on the cleared history, under the new journal
5254                // id: cancel it first, so it ends (and is saved) in the old
5255                // session with nothing sent.
5256                if self.turn_awaiting_start.is_some() {
5257                    self.cancel_turn(crate::engine::interrupt::InterruptReason::User).await;
5258                }
5259                // Before `clear` saves the old session: a ping in flight is
5260                // aborted and booked to it, not to the new one.
5261                self.cache_keepwarm_conversation_replaced();
5262                // `clear` saves the old session directly: queued saves of it
5263                // must land first, never after.
5264                self.persister.flush().await;
5265                self.conv.clear(&self.runtime).await;
5266                self.runtime
5267                    .begin_conversation(Some(self.conv.session.id.clone()));
5268                self.set_journal_id(self.conv.session.id.clone());
5269                // F10: lock follows the new session id.
5270                self.reacquire_session_lock(&self.conv.session.id.clone());
5271                self.emit(SessionEventWire::Cleared {
5272                    session_id: self.conv.session.id.clone(),
5273                });
5274                self.emit_conversation();
5275            }
5276            SessionCommand::Save => {
5277                self.save().await;
5278            }
5279            SessionCommand::Query { id, query } => self.query(id, query).await,
5280            // `/close` ends the session, which no other engine command can.
5281            SessionCommand::EngineCommand { id, name, arg } if name == CLOSE_COMMAND => {
5282                if let Some(reason) = self.close_command(id, &arg) {
5283                    return ControlFlow::Break(reason);
5284                }
5285            }
5286            SessionCommand::EngineCommand { id, name, arg } => {
5287                self.engine_command(id, name, arg).await
5288            }
5289            SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
5290            SessionCommand::Detach { client } => {
5291                self.detach(client);
5292                // Nobody left to notice a stale period: confirm the prefix
5293                // now rather than at the next ping, so a session whose next
5294                // request already changed is free to park.
5295                if self.attached.is_empty() && self.cache_keepwarm.active() {
5296                    self.cache_keepwarm_recheck().await;
5297                }
5298            }
5299            SessionCommand::End { reason } => {
5300                // `finish` cancels a running turn for this reason; note it
5301                // before the revoke below cancels a driver turn first.
5302                self.note_cancel_cause(match reason {
5303                    EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5304                    _ => crate::engine::interrupt::InterruptReason::Host,
5305                });
5306                if self.driver.is_some() {
5307                    self.driver_revoke("session ending");
5308                }
5309                return ControlFlow::Break(reason);
5310            }
5311            SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
5312                "resync not supported yet".into(),
5313            )),
5314            // A3 bodies live in actor_cmds.rs; B1 (Checkpoint), B3 (KeepWarm)
5315            // fill in the rest.
5316            SessionCommand::SubmitPrepared {
5317                messages,
5318                user_text,
5319            } => self.submit_prepared(messages, user_text).await,
5320            SessionCommand::PluginCommand {
5321                id,
5322                plugin,
5323                name,
5324                arg,
5325            } => self.plugin_command(id, plugin, name, arg).await,
5326            SessionCommand::Resume { id, query } => self.resume(id, query).await,
5327            SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
5328            SessionCommand::AdoptWorkers { workers } => self.adopt_workers(workers),
5329            SessionCommand::RestoreToolActivations { activations } => {
5330                self.restore_tool_activations(activations)
5331            }
5332            SessionCommand::ReloadAborted => self.reload_aborted().await,
5333            // Parked sessions returned above (their evict deadline stands).
5334            SessionCommand::ArmPark => self.rearm_park(),
5335            SessionCommand::Park => {
5336                // Explicit request: end an active keep-warm period, then park.
5337                self.cache_keepwarm_park_requested();
5338                if let std::ops::ControlFlow::Break(reason) = self.park().await {
5339                    return ControlFlow::Break(reason);
5340                }
5341            }
5342            SessionCommand::TakeInput => self.take_input(from),
5343            SessionCommand::KeepWarm { on } => {
5344                self.keep_warm = on;
5345                self.meta.pinned = on; // the attach snapshot's copy
5346                self.keep_warm_cell.store(on, std::sync::atomic::Ordering::Release);
5347                self.note_pin();
5348                // Unpinned with nobody who ever typed: back to the old rule.
5349                if !on && !self.cache_keepwarm.interactive_seen && self.cache_keepwarm.active() {
5350                    self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::Disabled);
5351                }
5352                self.rearm_park();
5353                self.emit(SessionEventWire::SystemNotice(if on {
5354                    PIN_ON_NOTICE.to_string()
5355                } else {
5356                    PIN_OFF_NOTICE.to_string()
5357                }));
5358            }
5359            SessionCommand::HostEvent(ev) => match ev {
5360                HostEvent::ExtensionNotification {
5361                    extension_id,
5362                    method,
5363                    params,
5364                } => self.emit(SessionEventWire::ExtensionNotification {
5365                    extension_id,
5366                    method,
5367                    params,
5368                }),
5369                HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
5370            },
5371            // E-P3: driver start handler.
5372            SessionCommand::DriverStart { plugin, command, arg } => {
5373                self.driver_start(plugin, command, arg);
5374            }
5375        }
5376        ControlFlow::Continue(())
5377    }
5378
5379    // ── teardown (tui/mod.rs:352-432 + chat.rs shutdown) ─────────────────
5380
5381    async fn finish(&mut self, reason: EndReason) {
5382        // Ended on purpose (`/close`, `daemon close`, a client's quit): the
5383        // pin goes with it. A daemon stop, reload or crash keeps it.
5384        if self.keep_warm && reason == EndReason::ClientQuit {
5385            self.keep_warm = false;
5386            self.note_pin();
5387        }
5388        self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
5389        // Before on_session_end clears the keyed injection (STEP 2): a hook
5390        // not yet called leaves the line; one in flight finishes holding the
5391        // gate and drops what it injects (`SessionStart::end`).
5392        self.session_start.end();
5393        self.lifecycle.store(
5394            SessionLifecycle::Ending as u8,
5395            std::sync::atomic::Ordering::Release,
5396        );
5397        // Quitting the client mid-turn is the user's interruption; every
5398        // other end reason is the host's.
5399        let interrupt = match reason {
5400            EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5401            _ => crate::engine::interrupt::InterruptReason::Host,
5402        };
5403        // Before the revoke below cancels a driver turn.
5404        self.note_cancel_cause(interrupt);
5405        // E-P3: clean up driver before teardown.
5406        if self.driver.is_some() {
5407            self.driver_revoke("session ending");
5408        }
5409        if self.streaming {
5410            self.cancel_turn(interrupt).await;
5411        }
5412        self.abort_compaction();
5413        // Outstanding prompts are cancelled (tool sees `None`).
5414        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
5415            let _ = tx.send(None);
5416            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
5417        }
5418
5419        let parked = !self.conv.is_live();
5420        let session_id = (**self.journal_id.load()).clone();
5421        let api_messages = if parked {
5422            None
5423        } else {
5424            Some(self.conv.api_messages.clone())
5425        };
5426
5427        // STEP 1: save — own bounded budget, highest priority. A parked
5428        // session is already on disk: end record only. The wait covers
5429        // everything queued, including the cancelled turn's save.
5430        let persist = self.config.persist;
5431        if !parked {
5432            self.request_save();
5433        }
5434        let persister = &self.persister;
5435        let save_fut = async {
5436            if persist {
5437                if !persister.flush().await {
5438                    tracing::warn!(session = %session_id, "session end: save failed");
5439                }
5440                let mut index_record =
5441                    crate::core::session_index::SessionIndexRecord::end(&session_id);
5442                index_record.turns = api_messages.as_ref().map(|m| m.len());
5443                if let Err(err) = crate::core::session_index::append_record(&index_record) {
5444                    tracing::warn!("failed to append session end index record: {}", err);
5445                }
5446            }
5447        };
5448        if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
5449            .await
5450            .is_err()
5451        {
5452            tracing::warn!(
5453                budget_secs = budgets::SAVE_TIMEOUT_SECS,
5454                "session save timed out — data may be incomplete"
5455            );
5456        }
5457
5458        // STEP 2 (C2): on_session_end — per session, concurrent, fail-open,
5459        // own budget; clears this session's keyed injection.
5460        let hook_bus = Arc::clone(&self.hook_bus);
5461        crate::extensions::loader::emit_session_end(
5462            &hook_bus,
5463            &session_id,
5464            api_messages,
5465            budgets::HOOKS_TIMEOUT,
5466        )
5467        .await;
5468
5469        // STEP 3: bounded observability flush (live only).
5470        if self.runtime.is_live() {
5471            if let Some(outcome) = self
5472                .runtime
5473                .shutdown_observability_async(
5474                    crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
5475                )
5476                .await
5477            {
5478                if !outcome.is_flushed() {
5479                    tracing::warn!(
5480                        stats = ?outcome.stats(),
5481                        "observability flush timed out — detached worker keeps draining"
5482                    );
5483                }
5484            }
5485        }
5486
5487        // Inbox watcher, per-session UDS, registry entry, keyed injection.
5488        self.background.shutdown();
5489        self.emit(SessionEventWire::Ended { reason });
5490    }
5491
5492    pub fn id(&self) -> &SessionId {
5493        &self.id
5494    }
5495}
5496
5497/// The tokio task. `SessionTask::run` is the reactor loop.
5498pub struct SessionTask(SessionActor);
5499
5500async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
5501    match stream {
5502        Some(s) => s.next().await,
5503        None => std::future::pending().await,
5504    }
5505}
5506
5507/// The conversation's title for listings: the saved title, else what
5508/// `Session::auto_title` will make of the first user message.
5509fn conversation_title(conv: &ConversationState) -> Option<String> {
5510    if !conv.session.title.is_empty() {
5511        return Some(conv.session.title.clone());
5512    }
5513    conv.api_messages
5514        .iter()
5515        .filter(|m| m["role"].as_str() == Some("user"))
5516        .find_map(|m| agent_core::core::session::user_content_for_display(&m["content"]))
5517        .map(|t| t.chars().take(80).collect())
5518}
5519
5520/// Whether the 1 Hz `SubagentRows` tick keeps running: always during a turn,
5521/// and after it while any worker is still running (a background worker's
5522/// progress events rode the ended turn's stream; the rows carry it now).
5523fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
5524    streaming || any_running
5525}
5526
5527/// Which running workers an interrupted turn cancels. Only the user's own
5528/// interrupt of a live session is turn-scoped: Esc stops what that turn
5529/// started, not reactive workers inherited from earlier turns (which the
5530/// user may be waiting on). Quitting/teardown (`ending`), the cost cap, host
5531/// checkpoints/restarts and driver revocation stop every running worker.
5532fn worker_cancel_scope(
5533    reason: crate::engine::interrupt::InterruptReason,
5534    ending: bool,
5535    turn_generation: u64,
5536) -> crate::runtime::subagent::WorkerCancelScope {
5537    use crate::engine::interrupt::InterruptReason;
5538    use crate::runtime::subagent::WorkerCancelScope;
5539    match reason {
5540        InterruptReason::User if !ending => WorkerCancelScope::Turn(turn_generation),
5541        _ => WorkerCancelScope::All,
5542    }
5543}
5544
5545async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
5546    match tick {
5547        Some(t) => {
5548            t.tick().await;
5549        }
5550        None => std::future::pending().await,
5551    }
5552}
5553
5554async fn poll_compaction(
5555    job: &mut Option<CompactionJob>,
5556) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
5557    match job {
5558        Some(j) => (&mut j.task).await,
5559        None => std::future::pending().await,
5560    }
5561}
5562
5563async fn park_timer(deadline: Option<tokio::time::Instant>) {
5564    match deadline {
5565        Some(t) => tokio::time::sleep_until(t).await,
5566        None => std::future::pending().await,
5567    }
5568}
5569
5570/// `revoked_turn_deadline`: same shape as `park_timer`.
5571async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
5572    match deadline {
5573        Some(t) => tokio::time::sleep_until(t).await,
5574        None => std::future::pending().await,
5575    }
5576}
5577
5578/// (P11) Prompt-abandonment deadline: same shape as `park_timer`. `None` =
5579/// disabled (no abandoned prompt, or the feature is off) → pends forever.
5580async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
5581    match deadline {
5582        Some(t) => tokio::time::sleep_until(t).await,
5583        None => std::future::pending().await,
5584    }
5585}
5586
5587/// The reload fence's backstop deadline; pends forever when unfenced.
5588async fn reload_fence_timer(deadline: Option<tokio::time::Instant>) {
5589    match deadline {
5590        Some(t) => tokio::time::sleep_until(t).await,
5591        None => std::future::pending().await,
5592    }
5593}
5594
5595/// 200 ms cadence, gated on driver state — `None` driver+pending → pending forever.
5596async fn driver_tick_timer(
5597    has_driver: bool,
5598    has_pending: bool,
5599    interval: &mut tokio::time::Interval,
5600) {
5601    if has_driver || has_pending {
5602        interval.tick().await;
5603    } else {
5604        std::future::pending().await
5605    }
5606}
5607
5608/// How long a session waits for process-level extension discovery before
5609/// `on_session_start`: `startup.extensions_ready_timeout_secs`, or the
5610/// budget constant when that is 0.
5611fn extensions_ready_timeout(config: &crate::SynapsConfig) -> Duration {
5612    match config.startup.extensions_ready_timeout_secs {
5613        0 => budgets::EXTENSIONS_READY_TIMEOUT,
5614        secs => Duration::from_secs(secs),
5615    }
5616}
5617
5618/// This session's `on_session_start` task, while it is in line or running.
5619async fn session_start_task(
5620    start: &mut super::session_start::SessionStart,
5621) -> std::result::Result<bool, tokio::task::JoinError> {
5622    match start {
5623        super::session_start::SessionStart::Queued { task, .. } => task.await,
5624        _ => std::future::pending().await,
5625    }
5626}
5627
5628impl SessionTask {
5629    pub fn id(&self) -> &SessionId {
5630        self.0.id()
5631    }
5632
5633    /// Test seam: this session's subagent registry, to seed fake workers
5634    /// before `run()` (see `EngineHost::create_session_unspawned`).
5635    #[cfg(any(test, feature = "testing"))]
5636    pub fn subagent_registry(
5637        &self,
5638    ) -> Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>> {
5639        Arc::clone(self.0.runtime.subagent_registry())
5640    }
5641
5642    /// Test seam: a clone of this session's runtime. Clones share the
5643    /// memory-consent state, so a harness can revoke consent the way the
5644    /// `memory_context` tool's capability does (`memory_context_disable`).
5645    #[cfg(any(test, feature = "testing"))]
5646    pub fn runtime_for_harness(&self) -> crate::Runtime {
5647        (*self.0.runtime).clone()
5648    }
5649
5650    /// Test seam: this session's event queue (what a worker's finalizer
5651    /// pushes its completion wake into).
5652    #[cfg(any(test, feature = "testing"))]
5653    pub fn event_queue(&self) -> Arc<crate::events::EventQueue> {
5654        Arc::clone(&self.0.event_queue)
5655    }
5656
5657    /// Unbiased select (the TUI loop is unbiased too).
5658    pub async fn run(mut self) {
5659        let queue = Arc::clone(&self.0.event_queue);
5660        let reason = loop {
5661            let actor = &mut self.0;
5662            tokio::select! {
5663                cmd = actor.cmd_rx.recv() => match cmd {
5664                    Some(cmd) => {
5665                        if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
5666                            break reason;
5667                        }
5668                    }
5669                    // Every handle dropped: nobody can ever reach us again.
5670                    None => break EndReason::HostShutdown,
5671                },
5672                Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
5673                // A held wake (and its permit) stays queued.
5674                _ = queue.notified(), if !actor.holds(WorkSource::QueueWake) => actor.on_queue_wake().await,
5675                _ = next_tick(&mut actor.subagent_tick) => {
5676                    actor.publish_subagent_rows();
5677                    actor.flush_turn_draft();
5678                }
5679                _ = park_timer(actor.park_deadline), if !actor.holds(WorkSource::ParkTimer) => {
5680                    if let std::ops::ControlFlow::Break(reason) = actor.park().await {
5681                        break reason;
5682                    }
5683                }
5684                _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
5685                    actor.on_prompt_abandon_deadline();
5686                }
5687                _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()), if !actor.holds(WorkSource::KeepWarmTimer) => {
5688                    actor.on_cache_keepwarm_due().await;
5689                }
5690                res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
5691                    actor.on_cache_keepwarm_done(res);
5692                }
5693                res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
5694                ran = session_start_task(&mut actor.session_start) => {
5695                    actor.session_start_finished(ran).await;
5696                },
5697                _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval), if !actor.holds(WorkSource::DriverTick) => {
5698                    actor.driver_tick().await;
5699                }
5700                _ = reload_fence_timer(actor.reload_fence.as_ref().map(|f| f.deadline)) => {
5701                    actor.reload_fence_expired().await;
5702                }
5703                _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
5704                    actor.finish_revoked_turn(false).await;
5705                }
5706                ev = next_stream_event(&mut actor.stream) => match ev {
5707                    Some(ev) => actor.on_stream_event(ev).await,
5708                    // A revoked turn's stream ending is its end.
5709                    None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
5710                    None => {
5711                        // Stream ended without a terminal event: defensive reset.
5712                        // P5: capture_terminal(None) = EOF → revoke driver.
5713                        if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
5714                            let terminal = super::driver::capture_terminal(None, false);
5715                            if let Some(terminal) = terminal {
5716                                if let Some(driver) = actor.driver.as_mut() {
5717                                    let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
5718                                    // F-NEW-1 (shady): EOF is a Blocked terminal.
5719                                    // Emit a DriverTurnOutcome before revoking so a
5720                                    // client tracking outcomes sees the same
5721                                    // outcome+revoke pair the Error path produces —
5722                                    // not a turn that silently vanishes.
5723                                    let sel = driver.selection.clone();
5724                                    let fb = driver.grant.feedback_enabled()
5725                                        .then(|| driver.completed_feedback.to_string());
5726                                    actor.emit(SessionEventWire::DriverTurnOutcome {
5727                                        outcome: crate::extensions::session_driver::Outcome::Blocked,
5728                                        selection: sel,
5729                                        feedback: fb,
5730                                    });
5731                                    if let Some(reason) = reason {
5732                                        actor.driver_revoke(&reason);
5733                                    }
5734                                }
5735                            }
5736                        }
5737                        actor.clear_stream();
5738                        actor.emit_conversation();
5739                        actor.announce_idle().await;
5740                    }
5741                },
5742            }
5743        };
5744        self.0.finish(reason).await;
5745    }
5746}
5747
5748/// Wait (bounded by [`budgets::WORKER_RELOAD_STOP_TIMEOUT`]) until every worker
5749/// `stop_for_reload` returned has settled its finalizer: a stopped worker's
5750/// resume archive is on disk and its in-flight tool was dropped, which kills
5751/// that tool's process group (after the exec nothing could); a worker that
5752/// finished on its own has pushed its completion wake into the queue the
5753/// reload carries. Returns the ids the reload stopped.
5754async fn await_stopped_workers(
5755    session: &str,
5756    stopping: Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)>,
5757) -> Vec<String> {
5758    if stopping.is_empty() {
5759        return Vec::new();
5760    }
5761    let ids: Vec<String> = stopping.iter().map(|(id, _)| id.clone()).collect();
5762    tracing::info!(session, workers = ?ids, "reload: stopping running workers");
5763    let deadline = tokio::time::Instant::now() + budgets::WORKER_RELOAD_STOP_TIMEOUT;
5764    loop {
5765        let pending: Vec<&str> = stopping
5766            .iter()
5767            .filter(|(_, s)| !s.read().unwrap_or_else(|p| p.into_inner()).completion_settled)
5768            .map(|(id, _)| id.as_str())
5769            .collect();
5770        if pending.is_empty() {
5771            break;
5772        }
5773        if tokio::time::Instant::now() >= deadline {
5774            tracing::warn!(
5775                session,
5776                workers = ?pending,
5777                "reload: workers did not stop in time; only their last archive survives the exec"
5778            );
5779            break;
5780        }
5781        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5782    }
5783    stopping
5784        .iter()
5785        .filter(|(_, s)| s.read().unwrap_or_else(|p| p.into_inner()).stopped_by_reload)
5786        .map(|(id, _)| id.clone())
5787        .collect()
5788}
5789
5790#[cfg(test)]
5791mod worker_cancel_scope_tests {
5792    use super::worker_cancel_scope;
5793    use crate::engine::interrupt::InterruptReason;
5794    use crate::runtime::subagent::WorkerCancelScope;
5795
5796    #[test]
5797    fn only_a_live_user_interrupt_is_turn_scoped() {
5798        assert_eq!(
5799            worker_cancel_scope(InterruptReason::User, false, 7),
5800            WorkerCancelScope::Turn(7),
5801            "Esc stops the interrupted turn's workers only"
5802        );
5803        assert_eq!(
5804            worker_cancel_scope(InterruptReason::User, true, 7),
5805            WorkerCancelScope::All,
5806            "quitting the client mid-turn tears every worker down"
5807        );
5808        for reason in [
5809            InterruptReason::CostCap,
5810            InterruptReason::Restart,
5811            InterruptReason::Host,
5812            InterruptReason::Driver,
5813        ] {
5814            assert_eq!(
5815                worker_cancel_scope(reason, false, 7),
5816                WorkerCancelScope::All,
5817                "{reason:?} stops every running worker"
5818            );
5819        }
5820    }
5821}
5822
5823#[cfg(test)]
5824mod subagent_tick_tests {
5825    use super::keep_subagent_tick;
5826
5827    #[test]
5828    fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
5829        assert!(keep_subagent_tick(true, false), "during a turn, always");
5830        assert!(keep_subagent_tick(true, true));
5831        assert!(
5832            keep_subagent_tick(false, true),
5833            "after the turn, while a background worker runs"
5834        );
5835        assert!(
5836            !keep_subagent_tick(false, false),
5837            "stops once the turn is over and no worker runs"
5838        );
5839    }
5840}
5841
5842#[cfg(test)]
5843mod parked_evict_tests {
5844    use super::parked_evict_after_from;
5845    use super::prompt_abandon_timeout_from;
5846    use std::time::Duration;
5847
5848    #[test]
5849    fn parked_evict_defaults_to_one_hour_and_honours_never() {
5850        // cfg_secs = 3600 mirrors the DaemonConfig default.
5851        let d = 3600u64;
5852        assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
5853        assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
5854        for never in ["never", "0", "off"] {
5855            assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
5856        }
5857        assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5858        // env absent → config wins; config 0 disables; env still beats config.
5859        assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
5860        assert_eq!(parked_evict_after_from(None, 0), None);
5861        assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
5862    }
5863
5864    #[test]
5865    fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
5866        // cfg_secs = 3600 mirrors the DaemonConfig default.
5867        let d = 3600u64;
5868        assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
5869        assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
5870        // Disabled sentinels ⇒ None ⇒ pure pre-#112 (prompt survives forever).
5871        for never in ["never", "0", "off"] {
5872            assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
5873        }
5874        // Garbage falls back to the safe default rather than disabling the guard.
5875        assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5876        // env absent → config wins; config 0 disables; env still beats config.
5877        assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
5878        assert_eq!(prompt_abandon_timeout_from(None, 0), None);
5879        assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
5880    }
5881}